Reactのstateは古い値に更新された!たまにあるstale closure問題
この記事はUMITRON Advent Calendar 2021 11日目の記事です。
初めに
Reactを書くときに、たまにはstateが古い値に更新されてしまうことがあります。
それはほとんど、サードパーティライブラリにコールバックを渡すときや、非同期処理を行うときが多い印象です。そういう時にまず要注意のはstale closureという問題です。自分のハマった経験を書こうと思いました。
問題
自分がハマった例:
const App = () => {
const datePicker = React.useRef();
const [values, setValues] = React.useState({
a: 0,
b: "2021-12-10",
});
const onChangeA = () => {
setTimeout(() => {
setValues({
...values,
a: values.a + 1,
})
}, 5000)
};
const onChangeB = (selectedDates, dateStr, instance) => {
setValues({
...values,
b: dateStr
})
};
React.useEffect(() => {
flatpickr(datePicker.current, {
onValueUpdate: onChangeB,
});
}, []);
return (
<div>
<div>
<span>a: {values.a}</span>
<button onClick={onChangeA}>
increment "a" by 1
</button>
</div>
<div>
<span>b: </span>
<input
type="date"
ref={datePicker}
value={values.b}
/>
</div>
</div>
)
}
このコードは一見、普通に初期値a: 0, b: "2021-12-10"というプロパティーの持っているobjectが初期stateになっていて、ボタンをクリックしたら、5秒後にaの値をプラス1にする、bのdatepicker(この例ではflatpickrというライブラリを使ってます)で値を選んだらbの値を更新するように見えます。
本当はどうでしょう?
まずは、ボタンを一回クリックします。
5秒後に、aの値が1になりました!
そしたら、datepickerで日付を変更してみましょう〜
あら、日付が更新されたタイミングで、aが0に戻っちゃいました!
なぜそうなりましたかね?
問題の部分をもう一回見てみましょう:
const onChangeB = (selectedDates, dateStr, instance) => {
console.log(values)
setValues({
...values,
b: dateStr
})
};
React.useEffect(() => {
flatpickr(datePicker.current, {
onValueUpdate: onChangeB,
});
}, []);
flatpickrがonValueUpdateのときに、onChangeBを呼びます。
onChangeBの中にvalues.bの値を更新しています。
onChangeBはclosureなので、外のscopeのvaluesをアクセスできます。(closures)
現在のstateは{a: 1, b: "2021-12-10"}なのに、consoleがlogした値はなんと{a: 0, b:"2021-12-10"}という古い値です!
でもよく見てみると、
closureが作成されたタイミングに、外の変数に対して参照を内部で保存しています、
このcomponentがmount時に、onChangeBが作成されていて、初期値のvalues(aがまだ0のままの時)への参照を持っています。
このonChangeBはflatpickrに渡しました。
flatpickrがonChangeBを実行するときに、初期値のvaluesを使って、新しいstateを作っています。
要するに、flatpickrの値が更新されるたびに、aの値がリセットしてしまいます。
解決
じゃどうしたらいいのでしょうか?
一例としては、
みなさんご存知のように、Reactのstateやpropsが更新されるたびに、UIが再レンダリングされます。それはclass componentの場合、render()関数は再実行されます。functional componentの場合、component自体が関数になっているので再実行されます。
ここでいうと、valuesが更新されたら、新しく作られたonChangeBをflatpickrに渡し直します。(ここの例だと、flatpickrをinitializeし直すことになります)
React.useEffect(() => {
flatpickr(datePicker.current, {
onValueUpdate: onChangeB,
});
}, [values]); <- ここ
こうしたら、valuesが更新されるたびに、最新のvaluesへの参照を持っているonChangeBが使われます。問題解決。
でも、例えばvaluesが頻繁に変わる何かの値だとしたら、ここ毎回flatpickrを再initializeするのはあまり良くないと思う方もいるのでしょう。
こういう時に、ReactのuseRefを使って最新の値を保存して、onChangeBの中に常に最新の値を参照するように変えましょう。
const valuesRef = React.useRef();
valuesRef.current = values; <- valuesが更新されたら、valuesRef.currentも更新される
const onChangeB = (selectedDates, dateStr, instance) => {
setValues({
...valuesRef.current, <- 常に最新の値を参照している
b: dateStr
})
};
React.useEffect(() => {
flatpickr(datePicker.current, {
onValueUpdate: onChangeB,
});
}, []);
componentが新しく実行されていても、valuesRefは新しく作られないけど(useRefの仕組み)、valuesRef.currentは定義し直され、新しいvaluesへの参照になっています。
初期flatpickrに渡されたonChangeBの中にvaluesRefへの参照は初期のままけど、valuesRef.currentは新しいvalues objectへの参照を持っているので、最新のstateを使って新しいstateを作ることができるようになりますた。ウェイ〜
もう一例
最後にonChangeAもちょっと見てみましょう。
ボタンを一回クリックする、その待っている間に、日付を変更します。
5秒を経ったら、aが1に更新されるはずですが、今回はaが1に更新されるタイミングで日付がリセットされてしまいました。。。
ちなみに、5秒の間にボタンを何回連打していても、5秒後にaの値は1しか増えないのです。
これも同じくstale closureの問題で、setTimeoutの中の関数が作られているタイミングで、その時のvaluesへの参照は保持されて、その待っている5秒の間にvaluesが新しいobjectになっていても、setTimeoutの中にはもう反映されないのですね。
ここで修正するには、Reactの公式documentsにいい例が書いてあります(https://reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often)
今回の例だと、こういう風にします:
const onChangeA = () => {
setTimeout(() => {
setValues((previousValues) => ({
...previousValues,
a: previousValues.a + 1,
}))
}, 5000)
};
以上はstale closureに苦労していた話です。
最後に
p.s.
自分の説明が下手で、以下のサイトはもっと上手く説明していてとても勉強になりました:
Discussion