使用している React のバージョンは16.8.4
。
レンダー後の処理を指定するための仕組み
React Hooks の一つであるuseEffect
は、レンダー後に実行したい処理を React に伝えるための仕組み。
useEffect(fn)
と記述すると、DOMの更新が終わったあとにfn
を実行する。
useEffect
はレンダー後に必ず実行される。最初にレンダーした際もそうだし、props
やstate
に変更があってレンダーし直した際もそう。そこに区別はない。
以下の例では、このコンポーネントが表示された際にeffect!
というログが流れる。
そしてボタンを押下した際にも、その都度effect!
というログが流れる。
import React, {useState, useEffect} from 'react'; const App = () => { const [state, setState] = useState(0); useEffect(() => { console.log('effect!'); }); return ( <> <div>{state}</div> <button type="button" onClick={() => { setState(state + 1); }} > increment </button> </> ); }; export default App;
公式ドキュメントではuseEffect
の第一引数に渡している関数(上記の例ではconsole.log('effect!')
を実行しているアロー関数)を「副作用関数」と呼んでいる(英語では単純にeffect
)ので、ここでもそれに倣って副作用関数と呼ぶことにする。
副作用関数はレンダーする度に新しく作られる
副作用関数は、レンダーされる度に毎回新しく作られ、それが呼び出される。
そのため、そのときのコンポーネントの状態に応じて処理の内容を変える、ということが可能になる。
先程の例のconsole.log
の部分を以下のように書き換えてみる。
console.log(state === 0 ? 'mounted!' : 'updated!');
こうすると、このコンポーネントが表示された際にmounted!
とログに表示され、以降、ボタンを押下するたびにupdated!
がログに表示される。
これは、レンダーする度に副作用関数が新しく作られることによって可能になっている。
このコンポーネントがマウントされたとき、以下の副作用関数が作られて実行される。
このときのstate
は0
なので、こうなっている。
() => {console.log(0 === 0 ? 'mounted!' : 'updated!');}
そしてボタンを押すとstate
が1
になり、DOMの更新が行われたあと、以下の副作用関数が作られて実行される。
() => {console.log(1 === 0 ? 'mounted!' : 'updated!');}
もう1度ボタンを押すとこう。
() => {console.log(2 === 0 ? 'mounted!' : 'updated!');}
つまり、公式ドキュメントにあるように「それぞれの副作用は特定のひとつのレンダーと結びついている」。
この仕組みを理解していないと、思った通りに副作用を実行できないことがある。
以下の例では、state
を1秒間隔でログに流す。
useEffect(() => { setInterval(() => { console.log(state); }, 1000); });
マウント時にstate
が0
の状態で実行されるので、0
が表示され続ける。
ここでボタンを押してstate
が1
になるとどうなるのかというと、1
が毎秒流れるだけでなく、引き続き0
もログに流れ続ける。
なぜこうなるかというと、ボタンを押下した際に呼び出される副作用関数は、マウント時に呼び出された副作用関数とは何の関係もない独立した関数なので、以前に呼び出された副作用関数には何も影響を与えない。
そのため、上記の例だと、ボタンを押す度に新しくsetInterval
が実行される。
では古いsetInterval
をクリアするにはどうすればいいのか。
クリーンアップと呼ばれる機能を使うと、新しく副作用関数を実行する前に、前回実行した副作用関数の処理に影響を与えることが出来る。
クリーンアップは、新しく副作用関数を実行する前に呼び出される
副作用関数のなかで関数を返すと、それがクリーンアップのための関数になる。
この関数は、次に副作用関数が実行される際に、それに先立って呼び出される。
以下の例だと、console.log(`Previous state is ${state}.`);
を実行しているアロー関数が、クリーンアップ。
const [state, setState] = useState(0); useEffect(() => { console.log(`Current state is ${state}.`); return () => { console.log(`Previous state is ${state}.`); }; });
この場合、マウント時にCurrent state is 0.
と表示される。
その後、ボタンを押すなどしてsetState(state + 1)
を実行してstate
が1
になると、次の副作用関数が呼ばれる前に、前回呼び出した副作用関数のクリーンアップが実行され、それから新しい副作用関数が呼ばれる。
そのため、まずPrevious state is 0.
が表示され、そのあとにCurrent state is 1.
が表示される。
以降、ボタンを押す度に同じ流れで処理が行われる。
先程のタイマーの例で言えば、以下のように書くことで、前回の副作用関数でセットしたタイマーがリセットされる。
useEffect(() => { const id = setInterval(() => { console.log(state); }, 1000); return () => { clearInterval(id); }; });
副作用関数の実行をスキップする
副作用関数はレンダーされる度に必ず実行されるが、副作用の内容によっては毎回呼び出す必要がない、あるいは呼び出したくないケースもある。
その場合、useEffect
の第二引数に配列を渡すことで、副作用関数を呼び出す条件を指定することが出来る。
useEffect(fn, [..deps])
と記述すると、deps
の内容を前回のfn
実行時の内容と比較して、変化があったときにのみ再びfn
を呼び出す。
以下の例では、inc state
ボタンを押下しても副作用関数は実行されず、マウント時と、inc keyState
ボタンを押下した場合にのみ、副作用関数が実行される。
import React, {useState, useEffect} from 'react'; const App = () => { const [state, setState] = useState(0); const [keyState, setKeyState] = useState(0); useEffect(() => { console.log('keyState has been incremented!'); }, [keyState]); return ( <> <div>{`${state}, ${keyState}`}</div> <button type="button" onClick={() => { setState(state + 1); }} > inc state </button> <button type="button" onClick={() => { setKeyState(keyState + 1); }} > inc keyState </button> </> ); }; export default App;
useEffect
の第二引数の配列にkeyState
を渡している。
マウント時に副作用関数を実行するのはこれまで通りだが、そのときのkeyState
は0
である。
次に、inc state
を押下する。そうするとstate
が更新されたので再びレンダーする。
このとき React は副作用関数を実行しようとするが、その前にkeyState
の値をチェックする。前回は0
だったが、今回も0
である。そのため、配列の値に変化がないため、副作用関数は実行されない。
今度はinc keyState
を押下してみる。そうするとkeyState
がインクリメントされ、前回の値が0
であったのに対して今回は1
なので、副作用関数が実行される。
配列には複数の値を渡すことが可能で、どれか一つでも前回の値と違っていれば、副作用関数が実行される。
副作用関数のなかで非同期処理を行う際の注意点
副作用関数のなかで非同期処理を行う場合、処理の順序が担保されない可能性があることに注意する。
副作用関数はレンダーされる毎に実行するわけだが、以前実行した副作用関数の非同期処理のほうが解決に時間がかかった場合、意図しない動きになる可能性がある。
少し長いが、サンプルを貼る。
import React, {useState, useEffect} from 'react'; const fetchUser = id => new Promise(resolve => { const responseTime = id === 1 ? 3000 : 1000; setTimeout(() => { resolve(`This is data ${id}`); }, responseTime); }); const App = () => { const [id, setId] = useState(null); const [message, setMessage] = useState('Please click button.'); const [apiStatus, setApiStatus] = useState(null); useEffect(() => { if (id) { (async () => { const res = await fetchUser(id); setApiStatus(`complete (user is ${id})`); setMessage(res); })(); } }, [id]); return ( <> <div>{message}</div> <br /> <div>API STATUS: {apiStatus}</div> <br /> <button type="button" onClick={() => { setId(1); }} > Fetch data of user number 1. </button> <button type="button" onClick={() => { setId(2); }} > Fetch data of user number 2. </button> </> ); }; export default App;
fetchUser
というAPIを叩く想定で、APIによってレスポンスの時間が異なる設定。
id
が1
のときは3秒で、2
のときは1秒でレスポンスが来る。
問題になるのは、1
のAPIを叩いた直後に2
のAPIを叩いたとき。
副作用関数の実行そのものは1
のほうが早いが、APIのレスポンスを待っている場合に2
の副作用関数が実行され、setMessage
まで実行されてしまう。そしてその後で、1
のレスポンスがようやく返ってきて、setMessage
が実行される。
そのため、message
の最終的な値はThis is data 1
になってしまう。
これに対処するには、クリーンアップを上手く使うとよい。
今回のケースでは、useEffect
を以下のように書き換える。
useEffect(() => { let didCancel = false; if (id) { (async () => { const res = await fetchUser(id); setApiStatus(`complete (user is ${id})`); if (!didCancel) setMessage(res); })(); } return () => { didCancel = true; }; }, [id]);
didCancel
の初期値はfalse
なので、そのままならsetMessage
は実行される。
だが、次の副作用関数が呼ばれると(今回のケースではid
が1
から2
に変わったタイミング)、前回の副作用関数のクリーンアップが実行され、前回の副作用関数におけるdidCancel
はtrue
になる。
このため、APIのレスポンスがようやく返ってきてawait
以降の処理を行う際に(!didCancel)
の条件が満たされず、前回の副作用関数のsetMessage
は実行されずに済む。