きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

useEffect の評価タイミングが分からないので泣きながら試す

useEffectuseState を組み合わせた 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>
  )
}

関数コンポーネント AppuseMyHook を利用しています。useMyHook の中では useStateuseEffect を利用しており 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_componentWillMountUNSAFE_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 を更新するための useEffects1 の更新に対して実行されるように追加しました。全体の更新のトリガーはマウント時に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} s1s2 も更新された値を同時に得る(タイミングが謎)
-[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 }
}

s1s2useEffect の定義の間に 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 のタイミングが前後してコンソールに出力される理由がよく分からないままです。 もしかしたらコンソール出力の部分がよくないのかもしれませんね。