⏱️

なぜuseEffect内のsetIntervalでハマるのか

2021/12/18に公開

こんなコードを書いたことがありますよね。1秒ごとcallbackを実行するhooksです。そして、 正常に動作せずに辛酸を舐めたことと思います。 私にはわかります。

export const useIntervalBy1s = (callback:() => void) => {
  useEffect(() => {
    const id = setInterval(callback,1000)
    return () => clearInterval(id)
  },[]) 
}

このhooksが厄介なのは、stateに依存しないcallbackだと問題に気づけない点です 。以下の使用例では正常に動いてみえます

useIntervalBy1s(() => console.log("1sごとによばれてるよ〜"))

しかし、stateを更新するcallbackの場合は期待通りに動きません。以下の使用例はcallbackでカウントアップしていますが、カウントアップは一度しか行われず以降出力されるは1のままです。

const [count,setCount] = usetState(0)
useIntervalBy1s(() => setCount(count + 1))
console.lg(count) //初期値0の後は常に1が出力される

useCallbackで囲っても同じです

const [count,setCount] = usetState(0)
const countup = useCallback(() => setCount(count + 1), [count]);
useIntervalBy1s(countup)
console.lg(count) //初期値0の後は常に1が出力される

なぜ...?

setIntervalはちゃんと毎秒動いています。callbackも動作しています。
原因は、countの初期state(=0)がクロージャ内に保存されて、callbackが実行されるたびに保存された0を使用しているからです。
つまり、以下のcountの値は常に0なのでsetCountに渡される値は常に1です。同じ値なのでコンポーネントの再描画ももちろん起こりません。これは stale-closure(古いクロージャ) と呼ばれています。

useIntervalBy1s(() => setCount(count + 1))

ピンと来ない方はsetIntervalをwindow.onresizeとかに置き換えてみると自然かもしれません。以下の例で何度ウィンドウのサイズを変えてもカウントアップは1度しか行われないですよね。useCallbackを使っても無駄ですよ。

const useResizeEvent = (callback) => {
  useEffect(() => {
    window.onresize = callback;
  }, []);
};
const [count, setCount] = useState(0);
useResizeEvent(() => setCount(count + 1));
console.lg(count) //ずっと0が出力される

解決策

当然、いくつかの解決策がありますが、うちいくつかは私の気に入らないものです。

1.callback内のsetStateに関数を渡してつねに最新のstateを得る。

なんで呼び出し元がそんなことを気にしないといけないんだ!!!!! と思うのでこの方法は嫌いです。一番簡単ですけどね。嫌なものは嫌。

  const [count, setCount] = useState(0);
  useIntervalBy1s(() => {
    setCount((preCount) => preCount + 1);
  });

2.useEffectの依存配列にcallbackを渡す

そもそもuseIntervalBy1sには依存配列が足りません。eslintのreact-hooks/exhaustive-depsプラグインが入ってる方は警告が出ていると思います。なのでちゃんとcallbackを依存配列に渡してあげましょう。

export const useIntervalBy1s = (callback:() => void) => {
  useEffect(() => {
    const id = setInterval(callback,1000)
    return () => clearInterval(id)
  },[callback]) 
}

一見うまく動いているように見えます。しかし、呼び出し元が再描画するたびにcallbackが作られるため、useEffect内の処理が毎回更新のたびに実行されます。つまりその度にsetIntervalとclearIntervalをやっています。まぁ別にいいですが、もう一歩必要そうです。

3.useRefを使ってcallbackを保持しuseEffectで更新する(完成!

hooks内でsetIntervalとclearIntervalを必要以上に呼び出さないためにはどうしたらいいでしょうか?setIntervalとclearIntervalは初回描画のみ実行さればいいはず、つまり、あれ...戻ってきてしまいました。

//こんにちは!最初に見たコードだよ。
export const useIntervalBy1s = (callback:() => void) => {
  useEffect(() => {
    const id = setInterval(callback,1000)
    return () => clearInterval(id)
  },[]) 
}

この場合、古いクロージャ、つまりstale-closureを常に参照してしまうのが問題でしたよね。なので、再描画されるたびに新しいcallbackを実行できればいいわけです。それはuseRefを使えば実現できます。
これでオールOKです。setIntervalとclearIntevalは初回のみ実行され問題なく動きます。

const useIntervalBy1s = (callback: () => void) => {
  const callbackRef = useRef<() => void>(callback);
  useEffect(() => {
    callbackRef.current = callback; // 新しいcallbackをrefに格納!
  }, [callback]);
  
  useEffect(() => {
    const tick = () => { callbackRef.current() } 
    const id = setInterval(tick, 1000);
    return () => {
      clearInterval(id);
    };
  }, []);//refはミュータブルなので依存配列に含めなくてもよい
};

なんで解決できたのか。

javascriptは関数を定義したとき、内部で使われている変数を関数内に閉じ込めます。つまり、以下のようにcallbackを渡したとき、countの変数は0のまま閉じ込められるため、setCountを呼び出してもcountの値は0のままになります。これは stale-closure(古いクロージャ) と呼ばれていますと前述しました。

useIntervalBy1s(() => setCount(count + 1))

なので新しい関数を定義して、新しいクロージャを作ってあげる必要があります。
完成の例を見て、useEffect内で定義されたtick関数が気になった人がいるでしょう。なんでもない関数ですが、最も重要です。これは新しいクロージャを作るために定義された関数です。 関数は定義された時に変数を閉じ込めます。つまりこのtick関数が定義されたとき、初めてcountが1のクロージャが作られるのです。

  useEffect(() => {
    const tick = () => { callbackRef.current() }//ここで新しいクロージャを作っている!
    const id = setInterval(tick, 1000);
    return () => {
      clearInterval(id);
    };
  }, []);

もしcallbackRef.currentをsetIntervalに渡すとうまく動きません。古いクロージャを参照しているため、countの値は0のままです。

const id = setInterval(callbackRef.current, 1000); // ダメー

おわりに

クロージャを使いこなせばjavascript中級者!っていうけどそんなに意識するシーンない気がする。

参考

refをなぜ依存配列に入れてはいけないのか気になった方はこちらをご覧ください。
https://github.com/facebook/react/issues/14387#issuecomment-503616820
クロージャについてはMDNよりwikiの方がわかりやすかったです。
https://ja.wikipedia.org/wiki/クロージャ
useEffect+setIntervalの記事はめちゃくちゃ擦られていますが、多分原点ここ
https://overreacted.io/making-setinterval-declarative-with-react-hooks/

Discussion