React Hooksを使っていくと、今までの感覚ではうまくいかないことがよくあります。
大前提:関数内の関数はクロージャを形成する
ReactではなくJavaScriptレベルの話ですが、関数内のローカル変数は関数の実行ごとに生成されます。そして、関数A
の内で別な関数B
を作成して、B
の中からA
のローカル変数を参照すると、A
の実行によって生成された変数が、B
から使うためにA
の終了後も生き残ることとなります。これが「クロージャ」です。
React Hooksの場合
React Hooksを使う場合、忘れてはならないのは、コンポーネントの描画ごとに関数が再実行されることです。ただ単にコンポーネント内で書いた関数ももちろんクロージャになりますが、コンポーネントの描画(=再実行)ごとに関数が再生成されますので、そのままReact内のイベントハンドラなどにセットしている場合は値の束縛の問題は生じません。
非同期処理に入る
これが問題となるのが、「useCallback
などでコールバックをキャッシュさせる場合」や「setTimeout
やfetch
など、React外の非同期処理に繋げる場合」です。前者については以前に触れましたが、破棄用の配列を適切に指定すれば問題なく乗り切れます。しかし、後者の場合はそうもいきません。
function SimpleTimer(){
const [value, setValue] = React.useState(1);
React.useEffect(() => {
if(value % 5 !== 0) return;
setTimeout(() => alert(value), 2000);
}, [value])
return(
<div>
<button type="button" onClick={() => setValue(num => num-1)}>-</button>
{value}
<button type="button" onClick={() => setValue(num => num+1)}>+</button>
</div>
);
}
ものすごく人工的な例ですが、「値が5の倍数になってから2秒後」にsetTimeout
の関数が実行されます。ただ、value
はsetTimeout
を仕掛けた時点のものを束縛してしまっているので、タイマーが動いている間にvalue
を変えても、それは読み取ってくれません。
mutableな値の置き場としてのref
ここで登場するのがuseRef
です。ref
という名前の通り、「コンポーネントへの参照」を入れるという印象も強いですが、単なる値の置き場としても利用可能で、Hooks内部ではインスタンス変数の代わりとしても利用可能です(React FAQ)。
では、今回の「コールバックの束縛」に対策を行ってみましょう。
const ref = React.useRef(null);
ref.current = () => alert(value);
React.useEffect(() => ref.current = null, [ref]);
const mutableCallback = React.useCallback(() => {
const func = ref.current;
func && func();
}, [ref]);
まず、ref
を作成して、次にref.current
に関数を代入しますが、この代入は毎回実行されます。そして、コールバックの実行時にref
から関数を取り出してそれを実行すれば、どんなタイミングで実行したとしても、直前にref.current
へ代入した、最新の値に束縛された関数が実行される、という寸法です。
なお、React.useEffect
は、アンマウント後にコールバックが呼ばれて意図しない動作をするのを防ぐため、コールバックの動作を止めるためのものです。
このようにして作ったmutableCallback
をsetTimeout
の引数にセットすれば、最新のvalue
を拾うようになります。
非同期コールバックをよく使うのであれば、useMutableCallback
のように切り出しておいてもいいかもしれません。