Kgtkr's Blog

React HooksのuseEffect内でsetInterval等を呼び出すとstate等の値が変化しない問題の解決策

2019/03/20
javascriptreact

初めに

以下のコードはstateの値が変わった時どのような動作をすると思いますか?
本質ではないのでclearInterval等の処理は省略しています。

const [x, setX] = useState(0);
useEffect(() => {
  setInterval(() => {
    console.log(x);
  }, 1000);
}, []);

このコード、思うようには動かずstateの値が変わってもずっと0が出力され続けます。
何故でしょうか?JSのクロージャをある程度理解している人なら分かると思います。
コンポーネント関数が呼ばれた時の動作を考えてみましょう。
初回呼び出しではuseState(0)を呼ぶとx0という値が入ります。
useEffectのコールバックも呼び出されます。この時setIntervalのコールバックはxという変数をキャプチャしています。
2回目以降の呼び出しではxは現在のstateです(このコードだけでは具体的な値は分からない)
useEffectの第二引数が[]なのでコールバックは呼び出されません。
つまりsetIntervalのコールバックのx常に1回目に呼び出された時のxをキャプチャしています。
またuseStateが原因で勘違いしやすい所ですが、N回目のxとN'回目のxは全く別の変数です。Reactがいい感じに管理してくれるので同じ変数のように見えますが、JSから見たら全くの別物です。ここがポイントです。
だからsetIntervalのコールバックがキャプチャしているxは常に1回目の呼び出しの値、つまり0となってしまうのです。
image.png

解決策

ではどう解決したらいいでしょうか?
useRefと使って以下のように書きます。

const [x, setX] = useState(0);
const refX = useRef(x);
useEffect(() => {
  refX.current = x;
}, [x]);
useEffect(() => {
  setInterval(() => {
    console.log(refX.current);
  }, 1000);
}, []);

なぜこれで上手く行くのでしょうか?
まず2行目〜5行目でxの値をrefX.currentに常にいれるようなコードを書いています。
また今回はsetIntervalのコールバックはxではなくrefXをキャプチャしています。
今回もN回目の呼び出しとN'回目の呼び出しのrefXが全く別の変数という事に変わりはないのですが、useRefが内部でいい感じにしてくれるおかげで、refXの値は常に一定です。だから全く別の変数であっても問題が起きません。
image.png

またxの値をrefXにいれるコードはよく使うのでカスタムフック化しておきましょう。

export function useValueRef<T>(val: T) {
  const ref = React.useRef(val);
  React.useEffect(() => {
    ref.current = val;
  }, [val]);
  return ref;
}

これを使うとこうなります。

const [x, setX] = useState(0);
const refX = useValueRef(x);
useEffect(() => {
  setInterval(() => {
    console.log(refX.current);
  }, 1000);
}, []);

場合によってはシンプルになる解決策

さっきはキャプチャする変数を参照にすることで解決しました。
しかし変数が増えると大変です。
そこで今度は関数を参照にしてみましょう。

const [x, setX] = useState(0);
const f = useValueRef(() => {
  console.log(x);
});
useEffect(() => {
  setInterval(() => {
    f.current();
  }, 1000);
}, []);

最初のコード例でも最新のxをキャプチャするクロージャは常に生成されていました。
しかしuseEffectの第二引数が[]であったため使われずに捨てられていました。
今回はこれを有効活用することで解決しています。f.currentは常に最新のxをキャプチャしたクロージャです。

fという名前が汚染されるとめんどうなので以下のようなカスタムフックを作って綺麗にしましょう。

export function useEffectRef<T>(effect: (ref: React.MutableRefObject<T>) => void | (() => void | undefined), val: T, deps?: React.DependencyList) {
  const ref = useValueRef(val);
  React.useEffect(() => effect(ref), deps);
}

これを使うとこうなります。


const [x, setX] = useState(0);
useEffectRef(f => {
  setInterval(() => {
    f.current();
  },1000);
}, () => {
  console.log(x);
}, []);

これなら変数が増えても楽ですね。