🎃

ReactでsetIntervalを安全に使うためのカスタムフックを考える

2021/12/31に公開約4,200字

この記事について

簡単なタイマーアプリを作りながら、setInterval を上手く使うためのカスタムフックuseInterval を実装していきます。

Gist に useInterval のコードを上げているので完成系だけ見たい方はこちらから見れます。

シンプルなタイマーを実装してみる

コンポーネントがマウントされたら自動的にタイマーが開始され、コンポーネットがアンマウントされたら自動的に止まるようにしたいとします。

とりあえず素直に実装してみます。

setInterval を便利に使うためのカスタムフックを実装してみます。

useInterval.ts
import { useEffect } from "react"

type Params = {
  onUpdate: () => void
}

export const useInterval = ({ onUpdate }: Params) => {
  useEffect(() => {
    const timerId = setInterval(() => onUpdate(), 1000)
    return () => clearInterval(timerId)
  }, [])
}

上記の useInterval を使ってタイマーを実装します。時間の管理は React らしく state で管理します。 (タイマーの初期値は 180 秒としましょう。)
1000 ミリ秒ごとにonUpdateが呼ばれsetStateで 1 秒ずつデクリメントしていきます。

Timer.tsx
import React, { useState } from 'react';
import { useInterval } from './useInterval';

export const Timer: React.FC = () => {
  const [time, setTime] = useState(180)
  useInterval({ onUpdate: () => setTime(time - 1)})
  return (<div>{time}</div>)
}

export default Timer;

完成です。さて、動かしてみます!!

画面の表示を見てみると、「180、179、、、、」

あれ、、、?動かない??

初期値の 180 から 179 には変わりましたが、それ以降動きが止まってしまいました。

これ React 初学者が必ず踏む落とし穴ではないでしょうか?
今回は、この現象を深堀していきたいと思います。

とりあえずログを出す

この現象を解決するためのなにかしらのヒントを得るために、とりあえずログを出してみます。

useInterval に渡す onUpdate の中身を以下のように書き換えます。

Timer.tsx
onUpdate: () => {
  console.log('onUpdate', 'time:', time)
  setTime(time - 1)
}

するとコンソールには

console
onUpdate time: 180
onUpdate time: 180
onUpdate time: 180
...

onUpdate が毎秒呼ばれていることは確認できますね。しかし、time の値が古いままです。
setTime を呼んでいるのに time が更新されない、、、?

いいえ。time が更新されていないのではなく、古いtimeをずっと参照し続けてしまっているのです。

犯人はこいつです。 useEffect の中で呼んでいる onUpdateの参照が古いままなのです。

useInterval.ts
export const useInterval = ({ onUpdate }: Params) => {
  useEffect(() => {
    const timerId = setInterval(() => onUpdate(), 1000)
    return () => clearInterval(timerId)
  }, [])
}

なるほど!となると、、、

useEffect の第2引数で onUpdate を渡しちゃえば良さそう!

useInterval.ts
export const useInterval = ({ onUpdate }: Params) => {
  useEffect(() => {
    const timerId = setInterval(() => onUpdate(), 1000)
    return () => clearInterval(timerId)
  }, [onUpdate])
}

動かしてみます!!

画面の表示を見てみると、「180、179、178、177、176、175、...」

動きましたね!!

、、、一見、期待通りに動いてるように見えますよね?

あれ?なんか違うかも?

useInterval の動きを注意深く見てみるとおかしな動きをしていることが分かります。

以下のようにログを仕込みます。

useInterval.ts
export const useInterval = ({ onUpdate }: Params) => {
  useEffect(() => {
    console.log('setInterval')
    const timerId = setInterval(() => onUpdate(), 1000)
    return () => {
      console.log('clearInterval')
      clearInterval(timerId)
    }
  }, [onUpdate])
}

さて、コンソールを見てみると...

setInterval
clearInterval
setInterval
clearInterval
setInterval
...

なんと、毎秒 clearIntervalsetInterval が呼ばれ続けてしまってます、、、

これは、 state が更新されると、コンポーネントに再レンダリングが走り、 onUpdate が再度渡され、useEffect が実行されるためです。

これはいけない。

整理すると、useEffectの中で呼ばれるonUpdateの参照は更新されて欲しい。しかし、useEffectの第2引数でonUpdateを渡してしまうと、毎秒 useEffect のクリーンアップが動いてしまう。。。

いったい、どうすれば、、、

ここで登場するのが useRef です。Ref オブジェクトに onUpdate の最新の参照を格納します。
useEffect の中では Ref オブジェクトに格納されている最新の onUpdate を呼び出します。

useInterval.ts
export const useInterval = ({ onUpdate }: Params) => {
  const onUpdateRef = useRef<OnUpdate>(() => {})
  useEffect(() => {
    onUpdateRef.current = onUpdate
  }, [onUpdate])
  useEffect(() => {
    const timerId = setInterval(() => onUpdateRef.current(), 1000)
    return () => clearInterval(timerId)
  }, [])
}

完成です!!!

これで onUpdate の最新の参照を onUpdateRef に持ちつつ、 無駄に clearInterval が走ることもありません!

useInterval の完成系

setInterval を使うためのカスタムフック useInterval の完成系は Gist に上げてます。

https://gist.github.com/akhrszk/776e7f136f8c167d9e66adff1a2d63e5

※こちらに上げているものは、マウント時に自動実行させるかどうかのフラグやインターバルの秒間をオプションで指定出来るようにしています。

参考

Discussion

ログインするとコメントできます