1
Help us understand the problem. What are the problem?

posted at

updated at

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

簡単なタイマーアプリを作りながら、setIntervalを上手く使うためのカスタムフックuseIntervalを実装していきます。
Gistに useIntervalのコードを上げているので完成系だけ見たい方はこちらから見れます。

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

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

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

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;

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

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

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

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

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

とりあえずログを出す

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

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 が更新されない :grey_question: :grey_question: :grey_question:

いいえ。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])
}

動かしてみます!! :laughing:

画面の表示を見てみると、「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 が呼ばれ続けてしまってます :dizzy_face:

これは、 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)
  }, [])
}

:tada: :tada: :tada: 完成です! :tada: :tada: :tada:

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

useIntervalの完成系

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

参考

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
1
Help us understand the problem. What are the problem?