useEffect の評価タイミングが分からないので泣きながら試す
useEffect
と useState
を組み合わせた Hook を作成しようとしたのですが、動作のタイミングが今ひとつわからずうまく処理が噛み合わない Hook ができてしまったので、簡単な例で挙動を探っていきます。
色々試してみましたが、結局よくわかりませんでした。以下の内容もいつも以上に理解しないままで書いていますがご容赦ください。
動作するコードは github に置いてあります。
useState と useEffect を組み合わせたシンプルな例
const useMyHook = (label: string) => { console.log(label, 'useMyHook') const [s1, setS1] = useState(0) useEffect(() => { setS1(s => { console.log(label, `useEffect(s1): increments s1(${s1} → ${s1+1})`) return s + 1 }) }, []) return { s1 } } let count = 0 const App: React.FC = () => { count++ const label = '-'.repeat(count-1) + `[${count}]` console.log(label, 'App') const state = useMyHook(label) console.log(label, `App: got ${JSON.stringify(state)}`) console.log(label, 'App: render') return ( <div className="App"> <header className="App-header"> {JSON.stringify(state)} </header> </div> ) }
関数コンポーネント App
が useMyHook
を利用しています。useMyHook
の中では useState
と useEffect
を利用しており useEffect(fn, [])
は fn
をマウント時の一度のみ実行します。
実行タイミングがわかりやすいように、評価回数をカウントしてコンソール出力のインデントとして表示します。
実行後のコンソール出力は以下のようになりました。実装を読んでいないので適当ですが、こんな流れだろうという解説を添えてみました。
console | 解説 |
---|---|
[1] App | |
[1] useMyHook | 最初に useMyHook を評価した時点では... |
[1] App: got {"s1":0} | ...初期状態の state が帰ってくる |
[1] App: render | App がレンダリング内容を返す |
[1] useEffect(s1): increments s1(0 → 1) | マウント時に呼ばれるように指定した useEffect が実行される |
-[2] App | useState の状態が更新されたので App が再評価される |
-[2] useMyHook | 再び useMyHook を評価し... |
-[2] App: got {"s1":1} | ...s1 が更新された state を得る |
-[2] App: render |
注目したいのは useEffect
の実行されるタイミングで、useMyHook
内で定義された時点では実行されずに、それを呼び出した App
が レンダリング内容を返した後で 実行されています。公式のドキュメントにも書いてありますね。
https://reactjs.org/docs/hooks-effect.html
What does useEffect do? By using this Hook, you tell React that your component needs to do something after render.
これを把握していないと、私のように「なんか副作用的な処理がうまく噛み合わない...」と処理を作り込んだ後で後悔することになります。実際、当初の私の理解では上記のコードを以下のように誤読していました。
- 初回に
useMyHook
が返す値はuseEffect
で更新された後の値{"s1":1}
- なので
App
は一度のみ評価される
実際は App
評価 → useEffect
評価 → App
再評価、という順で評価されます。余談ですが私のやろうとしていたことをクラスコンポーネントのライフサイクルメソッドで実装しようとすると
https://reactjs.org/docs/react-component.html
の UNSAFE_componentWillMount
や UNSAFE_componentWillUpdate
に相当しそうなので、React 的にはそもそもあまりよろしくない動作を実装しようとしていたことになりますね。
もう少し複雑なパターン
const useMyHook = (label: string) => { console.log(label, 'useMyHook') const [s1, setS1] = useState(0) const [s2, setS2] = useState(0) // will get increment by s1 update useEffect(() => { setS1(s => { console.log(label, `useEffect(s1): increments s1(${s1} → ${s1+1})`) return s + 1 }) }, []) useEffect(() => { setS2(s => { console.log(label, `useEffect(s1→s2): increments s2(${s2} → ${s2+1}) caused by s1(${s1}) update`) return s + 1 }) }, [s1]) return { s1, s2 } }
s2
を更新するための useEffect
を s1
の更新に対して実行されるように追加しました。全体の更新のトリガーはマウント時に1回だけ更新される s1
のみです。
実行後のコンソール出力は以下のようになりました。もうよく分からないですね。
console | 解説 |
---|---|
[1] App | |
[1] useMyHook | |
[1] App: got {"s1":0,"s2":0} | |
[1] App: render | |
[1] useEffect(s1): increments s1(0 → 1) | #1 |
-[2] App | #1 による再評価 |
-[2] useMyHook | |
[1] useEffect(s1→s2): increments s2(0 → 1) caused by s1(0) update | #2 |
-[2] App: got {"s1":1,"s2":1} | s1 も s2 も更新された値を同時に得る(タイミングが謎) |
-[2] App: render | |
--[3] App | #2, #3 両方による再評価...? |
--[3] useMyHook | |
-[2] useEffect(s1→s2): increments s2(1 → 2) caused by s1(1) update | #3 |
--[3] App: got {"s1":1,"s2":2} | |
--[3] App: render |
二つの useEffect
の評価されるタイミングの間に App
の評価タイミングが割り込んでいるのが気になりますね。
あと App
の再評価と State の更新のタイミングが前後しているように見えるのが気になります。
短期間に複数の useEffect
内で State が更新されたせいでこんな挙動になっているのでしょうか。
各 useEffect
に対して 0
に指定した setTimeout
を適用してみると App
の評価回数が1回多く増えました。
| [1] App | | | [1] useMyHook | | | [1] App: got {"s1":0,"s2":0} | | | [1] App: render | | | [1] useEffect(s1): increments s1(0 → 1) | #1 | | -[2] App | #1 による再評価 | | -[2] useMyHook | | | -[2] App: got {"s1":1,"s2":0} | #1 が反映 | | -[2] App: render | | | --[3] App | ...おそらく #2 による再評価 | | --[3] useMyHook | | | [1] useEffect(s1→s2): increments s2(0 → 1) caused by s1(0) update | #2 | | --[3] App: got {"s1":1,"s2":1} | #2 が反映 | | --[3] App: render | | | ---[4] App | ...おそらく #3 による再評価 | | ---[4] useMyHook | | | -[2] useEffect(s1→s2): increments s2(0 → 1) caused by s1(1) update | #3 | | ---[4] App: got {"s1":1,"s2":2} | #3 が反映 | | ---[4] App: render | |
JavaScript のタイマー処理は目的の時間ちょうどには行われない、ということによるものなのでしょうか。ただ、動作的にはこちらの方がしっくりくる気がします。が、依然として useEffect とコンポーネントの再描画のタイミングがよく分からないことになっています
もっと複雑なパターン
const useMyHook = (label: string) => { console.log(label, 'useMyHook') const [s1, setS1] = useState(0) const [s2, setS2] = useState(0) // will get increment by s1 update const [s3, setS3] = useState(0) // will get increment by s2 update useEffect(() => { setS1(s => { console.log(label, `useEffect(s1): increments s1(${s1} → ${s1+1})`) return s + 1 }) }, []) useEffect(() => { setS3(s => { console.log(label, `useEffect(s2→s3): increments s3(${s3} → ${s3+1}) caused by s2(${s2}) update`) return s + 1 }) }, [s2]) useEffect(() => { setS2(s => { console.log(label, `useEffect(s1→s2): increments s2(${s2} → ${s2+1}) caused by s1(${s1}) update`) return s + 1 }) }, [s1]) return { s1, s2, s3 } }
s1
と s2
の useEffect
の定義の間に s2
の更新タイミングによる s3
更新処理を挟みました。
console | 解説 |
---|---|
[1] App | |
[1] useMyHook | |
[1] App: got {"s1":0,"s2":0,"s3":0} | |
[1] App: render | |
[1] useEffect(s1): increments s1(0 → 1) | #1 |
-[2] App | #1 による再評価 |
-[2] useMyHook | |
[1] useEffect(s1→s2): increments s2(0 → 1) caused by s1(0) update | #2 |
[1] useEffect(s2→s3): increments s3(0 → 1) caused by s2(0) update | #3 |
-[2] App: got {"s1":1,"s2":1,"s3":1} | #1, #2, #3 が反映 |
-[2] App: render | |
--[3] App | |
--[3] useMyHook | |
-[2] useEffect(s1→s2): increments s2(1 → 2) caused by s1(1) update | #4 |
-[2] useEffect(s2→s3): increments s3(1 → 2) caused by s2(1) update | #5 |
--[3] App: got {"s1":1,"s2":2,"s3":2} | #4, #5 が反映 |
--[3] App: render | |
---[4] App | #4, #5 |
---[4] useMyHook | |
--[3] useEffect(s2→s3): increments s3(2 → 3) caused by s2(2) update | #6 |
---[4] App: got {"s1":1,"s2":2,"s3":3} | #6 が反映 |
---[4] App: render |
やっぱり App
の評価後に実行された useEffect
の結果が useMyHook
の返り値に反映されていますね...よくわからないです。useMyHook
の評価が始まった後で、それ以前の useEffect
が処理されているように見えるのでもしかしたら useEffect
の定義時に同じ箇所の useEffect
が呼ばれたかを確認して、呼ばれていなければそのタイミングで呼ぶ、みたいな処理をしている...とか...? でもそれだと useState
の値がよくわからなく...
ちょっとお手上げです。振る舞いから推測するより React Hook の実装を理解した方が近道かもしれません。
気が向いたら眺めますが、今回は以上です。
要点としては useEffect
の評価タイミングはレンダリング後、ということで、それ以上のことはよくわかりませんでした。コンポーネントの再描画のタイミングと useState
のタイミングが前後してコンソールに出力される理由がよく分からないままです。
もしかしたらコンソール出力の部分がよくないのかもしれませんね。