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] }