React hookにおけるTimeoutとTimeInterval【止まらない・重複する・増えない】

useEffect useRef が唯一の解決策

Timeout、TimeIntervalの動作に頭を抱えているあなた。多分、useEffectuseRefuseCallbackなどの文字を既にみてきたと思います。でも、避けてきませんでしたか?(私は避け続けていました。)

もはや、この壁から逃げることはできません。今対面している課題の解決には、useEffect・useRefしかないのです!!!!

大丈夫。めちゃくちゃ簡単。めちゃくちゃ便利全部使うわけじゃない!

とりあえず、コードだけ置いておく。知ってる。現場の人はまずは動くものが欲しい。時間ができたらまた戻ってきて理解して。

【サンプルコード】レンダーした時に1度だけ実行して、毎秒カウントし続けるタイマー

  • useEffectで、はじめの一度きりsetIntervalを起動している。(重複しない)
  • setCount( time + 1 )ではなくsetCount( c => c + 1 )としている。(最新のstateを取得している)

【サンプルコード】ボタンでカウントを始めたり止めたりするタイマー

  • useRefによってローカル変数が世界線を超えた変数になる
  • useCallbackでパフォーマンスを向上

【重複問題】setIntervalやSetTimeoutがめちゃくちゃカウントしだす理由

Reactは画面描画を何度も行うので、その度に関数が実行されていた。

例えばこのコード。普通ならバッチリ動く。(永遠と止まらないけど。)

export default function App() {
  const [count, setCount] = useState(0);
  setInterval(() => {
    setCount(count + 1);
  }, 500);
  return <div>count => {count}</div>;
}

でもreactではそうはいかない。レンダーの度にsetIntervalが何度も実行されて、いわゆるオーバーフロー、メモリリークが起きます。

  1. 読み込みと同時にsetIntervalが動き始めてcountの値を更新する。
  2. 1でcountの更新がされると、再レンダーされ、また新しいsetIntervalが動き始める。(一つ目のsetIntervalは動きっぱなし)
  3. 繰り返して処理が膨大になりオーバーフロー

一度だけ関数を実行したいからuseEffect(処理,[])を使う

useEffect()は第二引数にからの配列[]を受け取ると、レンダー後1度のみの処理をします。

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(()=>{
    setInterval(() => {
    setCount(count + 1);
  }, 500);
  },[])

  return <div>count => {count}</div>;
}

これで一番はじめの読み込み時だけ、setIntervalが動くようになりました。

が….ずっとcountが1のままです。古いstateを使い続けて計算をしてしまっているようです。

setStateしたstateが古い。最新のstateを使って関数を実行したい。

初回の読み込み時のcountは0です。useEffectで一番初めに読み込んだ関数setIntervalの中にあるcountも0なので、繰り返し中ずっとその値で計算をし続けます。0+1=1のままです。

どのようにして、更新したばかりの、新しいstateを計算に使うのでしょうか。

新しい state が前のstate に基づいて計算される場合は、setState に関数を渡す

[ count , setCount ]=useState(0);
setCount( c => c + 1);

setState()の引数に関数を渡すことによって、最新のstateの情報で計算をし続けてくれるようになりました。

【まとめその1】reactでタイマーを重複せずに動かすサンプルコード

記事の最初に貼ったやつと一緒です。

  • useEffectの第一引数に処理、第二引数に空配列を渡すことで、レンダー後1回きり処理を行ってくれます。これによって、タイマーが大量生成されることによるメモリリーク・オーバーフローすることを避けられます。
  • setStateの引数に関数を書くことによって、最新のstateを参照することができるようになります。
import React, { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return <div>count = {count}</div>;
}

【ちなみに】useEffectの第二引数に空配列[]じゃなくて、何かしらのstateを入れると、そのstateを監視してくれる

export default function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);
  useEffect(()=>{
      console.log('count changed')
  },[count]);
  return <div>count = {count}</div>;
}

このコードはuseEffectが二つあります。2個目のuseEffectの第2引数に[count]が指定されています。

こうすることによってこのuseEffectは、stateのcountを監視して、countが更新された時だけ中の処理をします。便利。

【止まらない問題】React においてsetIntervalやSetTimeoutがclearしても止まらない理由

まずは間違いのコード。止まらなくなったら、リフレッシュボタン押してあげてください。

  1. setInterval関数が入る箱intervalを用意して、null入れておく。
  2. startボタンが押されたらstart関数を実行して、実行内容のsetInterval関数箱intervalにしまう。
  3. stopボタンが押されたらstop関数を実行して、箱intervalの中の関数をclearIntervalする。

なぜこの仕組みが動かないのか。これにもReactのレンダー が関与している。

Reactではレンダーされる度に世界線が変わる。

つまり?

startボタンを押した時、世界線が変わってしまうのです。startボタンの処理で箱intervalsetInterval関数をしまうってありましたけど、それは新しい世界線での話。新しい世界線では箱interval==関数setinterval

でも元々のstop関数がいる世界線では箱intervalの中身はnullのまま。だからstopボタンを押しても、clearする対象のsetIntervalがない(カウントが止まらない)。

むしろstartボタンを押す度に世界線が増えていき、countは増え続ける。ブラウザ負担も増え続ける。

ちなみにcountは世界線を超えた超越概念なんだね。全ての世界線で同じcountを足していく(参照する)ことができる。

箱interval超越概念にして全ての世界線から参照できるようにすればいいんだ。それを可能にするのがuseRef

useRef()を使って、全ての世界線から参照することのできる超越概念を作る

  1. const intervalRef = useRef(null)をすることによって、箱intervalRefは全ての世界線から参照できる超越概念になった。
  2. 本筋とは関係ないけどif文で、何度もタイマー押したりできないようにした。
  3. useRefで作られた箱の中に更に .currentってスペースがあって、そこがモノを出し入れする場所。参照する場所。
  4. stopで参照しているintervalRef.currentは超越概念なので、違う世界線で定義されたsetIntervalを止めることができる

コメントアウトの番号と対応しています↑↓

export default function App() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);//1
  const start = () => {
    if (intervalRef.current !== null) {//タイマーが進んでいる時はstart押せないように//2
      return;
    }
    intervalRef.current = setInterval(() => {//3
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    if (intervalRef.current === null) {//タイマーが止まっている時はstart押せないように
      return;
    }
    clearInterval(intervalRef.current);//3
    intervalRef.current = null;//3 インターバルを止めたら箱の中身を空にしておく。
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

実際、これでもう完璧に動きます。

useRef、めちゃくちゃ便利。僕はこの世界線的考え方ですんなり理解できたけど、大幅な誤解があるかもしれない。

ついでにuseCallbackもみておこう。

useCallbackは結果が変わらないレンダーをスキップしてくれる優秀なやつ

さっきのコードは、startボタン/stopボタンを押す度に対応する関数を読みにor作りに行っていたけど(ちょっと怪しい)、useCallbackを使えば、今回押した時と、前回押した時で何か中身は変わっているかな?ってshallowなcomparison(浅い比較)をしてくれるらしい。意味不。

んで、特に中身が変わってなければレンダーをスキップするからパフォーマンスが上がるとかなんとか…ちょっと理解不足だけどとりあえずuseCallback使って、関数をmemo化して、使い回しても負荷かからないようにするよ!ってこと!!

export default function App() {
  console.log('renderLevel11');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  }, []);
  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }, []);
return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

【まとめその2】setInterval,setTimeoutが止まらない理由は世界線が違うから!

余裕があったら、useCallbackで関数定義の重複/レンダーを避けるとパフォーマンス良

  • useRef()と.currentを使って、超越概念を作り上げ、全世界線から戦勝できるようにしよう。
  • useCallbackは関数の結果が違うかどうかをさらっとみてくれて、一緒なら余計なことはスキップしてくれる優秀なやつだよ

以上!!!!!!!!!!!!!!!!!!!!!!