Make it to make it

いろいろ作ってアウトプットするブログ

React hooksのミニマルサンプル

リファクタプロセス

hooksを一つずつ導入

useState

カウンターアプリを、まずはstate hookを使って書いてみる。

import React, { useState } from 'react'
import ReactDOM from 'react-dom'

const Counter: React.FC = () => {
  const [count, setCount] = useState(0)
  const reset = (): void => setCount(0)
  const increment = (): void => setCount((c) => c + 1)

  return (
    <div>
      <div className="number-board">
        <span>count</span> <span>{count}</span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
        <button type="button" onClick={increment}>
          +1
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Counter />
  </React.StrictMode>,
  document.getElementById('root'),
)

useEffect

上記を応用したカウントダウンタイマー。

import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const reset = (): void => setTimeLeft(limit)
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) setTimeLeft(limit)
  }, [timeLeft, limit])

  return (
    <div>
      <div className="number-board">
        <span>time</span> <span>{timeLeft}</span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

useMemo

メモ化を導入してみる。

import React, { useState, useEffect, useMemo } from 'react'
import ReactDOM from 'react-dom'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const reset = (): void => setTimeLeft(limit)
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) setTimeLeft(limit)
  }, [timeLeft, limit])

  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}>
          {timeLeft}
        </span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

useCallback

setTimeLeft(limit)を呼んでるところ、reset()に共通化する。

import React, { useState, useEffect, useMemo } from 'react'
import ReactDOM from 'react-dom'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const reset = (): void => setTimeLeft(limit)
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) reset()
  }, [timeLeft, limit])

  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}>
          {timeLeft}
        </span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

eslint(react-hooks/exhaustive-deps)を入れていると、勝手にdependencies arrayの中身を正しいものにサジェストしてくれる。

React Hook useEffect has a missing dependency: 'reset'. Either include it or remove the dependency array.

そちらを修正すると、今度はresetメソッドに下線が引かれてuseCallback()を使うように促される。

The 'reset' function makes the dependencies of useEffect Hook (at line 26) change on every render. To fix this, wrap the 'reset' definition into its own useCallback() Hook.

これらを修正すると、下記のようになる。

import React, { useState, useEffect, useMemo, useCallback } from 'react'
import ReactDOM from 'react-dom'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const reset = useCallback((): void => setTimeLeft(limit), [limit])
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) reset()
  }, [timeLeft, limit, reset])

  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}>
          {timeLeft}
        </span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

Custom hookでロジックを分離・再利用

Custom hookを使った上で、containerとpresentational componentに分ける。

App.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { useTimer } from './hooks'

interface IPropsTimer {
  timeLeft?: number
  isPrime?: boolean
  reset?: () => void
}

const Timer: React.FC<IPropsTimer> = ({
  timeLeft = 0,
  isPrime = false,
  reset = () => undefined,
}) => {
  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: isPrime ? 'pink' : '' }}>{timeLeft}</span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

interface IPropsEnhancedTimer {
  limit: number
}

const EnhancedTimer: React.FC<IPropsEnhancedTimer> = ({ limit }) => {
  const [timeLeft, isPrime, reset] = useTimer(limit)

  return <Timer timeLeft={timeLeft} isPrime={isPrime} reset={reset} />
}

ReactDOM.render(
  <React.StrictMode>
    <EnhancedTimer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

hooks.ts

import { useState, useEffect, useMemo, useCallback, useRef } from 'react'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

export const useTimer = (limit: number): [number, boolean, () => void] => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const timerId = useRef<NodeJS.Timeout | undefined>()
  const reset = useCallback(() => setTimeLeft(limit), [limit])
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const clearTimer = (): void => {
      if (typeof timerId.current === 'undefined') {
        return
      }
      clearInterval(timerId.current)
    }

    reset()
    clearTimer()

    timerId.current = setInterval(tick, 1000)

    return clearTimer
  }, [limit, reset])

  useEffect(() => {
    if (timeLeft === 0) reset()
  }, [timeLeft, reset])

  return [timeLeft, primes.includes(timeLeft), reset]
}