Open21

Strict ModeなのにuseEffectが1回しか実行されないのなんで?

Yug (やぐ)Yug (やぐ)

Reactチュートリアルのこのチャレンジ問題を解いてるときにふと出た疑問。
https://ja.react.dev/learn/you-might-not-need-an-effect#cache-a-calculation-without-effects

ボタンをクリックするたびに、このconsoleが1回しか実行されない理由がわからない。

// useEffect
export default function App() {
    const [toggle, setToggle] = useState(false);

    useEffect(() => {
        console.log('fire');
    }, [toggle])

    return (
        <button onClick={() => setToggle(prev => !prev)}>トグル</button>
    )
}

ちなみに、初回レンダリング時は2回実行されてる。なぜ?

つまりなぜ以下のようになるのかわからない。

  • マウント時は Setup → Cleanup → Setup の順で2回実行
  • 更新時はCleanup → Setupと1回のみ実行
Yug (やぐ)Yug (やぐ)

あと、以下のuseMemoによるconsoleは毎度2回実行されるが、これは「Strice Modeによる2回目のレンダリングではメモ化した値も破棄されるから」とClaudeは言ってた。もしそうならなるほど、て感じだけど本当かどうかは確かめねば

// useMemo
export default function App() {
    const [toggle, setToggle] = useState(false);

    useMemo(() => {
        console.log('fire');
    }, [toggle]);

    return (
        <button onClick={() => setToggle(prev => !prev)}>トグル</button>
    )
}
Yug (やぐ)Yug (やぐ)

んー、というかそもそもuseEffectの実行タイミングをよくわかっていないことに気付いた。useStateの内部構造も全然わかってないし。もちろんuseMemoの内部構造もわかってない。

useEffect実行は「レンダリングの後」だと大雑把に認識しているが、そもそも「レンダリングの後」ってなんやねん。「レンダリング」というReactの内部構造側ではなくユーザー側が使う単語の厳密な定義がわかっていない。

厳密には「レンダーの後」なのか「レンダー & コミットの後」なのか...。これもuseStateとuseEffectの内部構造を理解しないとわからないやつか...?

Yug (やぐ)Yug (やぐ)

まずuseMemoの実行の流れを想像すると...

  1. ボタンが押される
  2. setToggleが呼び出される
  3. toggle更新がトリガーされる
  4. レンダー (=コンポーネント実行?)
  5. toggleの値が前回と変わっていることが認識される
  6. ので実際にtoggleが変更される
  7. toggleが変更されたことをuseMemoが検知し"fire"と出力
  8. ここでStrict Modeによる2度目のレンダーが走るのか?
  9. そうすると、イベントハンドラ内のsetStateの変更処理が実際に終わった直後の状態に戻ってそれ以降を再度やりなおすのか?そう考えるのが妥当ではある

あぁ、Strict Modeが具体的に何をやっているのかわからないことに気付いた。「2回レンダリング?レンダー?がされてる」みたいな浅すぎる理解しかない。レンダーを2回実行している?

いや、レンダーではなくマウントか?
https://ja.react.dev/learn/lifecycle-of-reactive-effects#how-react-verifies-that-your-effect-can-re-synchronize

というか初回レンダリング時って、toggleの値が変わったと見なすんかな?toggleというものが存在しなかったがfalseという初期値で誕生したのでそれを変更と認識している?でも「前回の値との変更が見られるか」を確認するのがレンダーだよな、であれば存在しなったものが生まれたらそれは前回の値を持っていないので「変更」とは言えないのでは?「変更」ではなく「誕生」でも変更とみなすという理解でOK?

Yug (やぐ)Yug (やぐ)

次にuseEffectの実行の流れを想像したいが...
これは一旦保留。そもそもわかっていないことが多すぎる。

Yug (やぐ)Yug (やぐ)

↓のコード試してもやっぱり更新するたびにuseEffectは1回(disconnect->connect)しか実行されないな
ただ、初回マウント時のみ2回実行されてるんだよなぁ...(connect->disconnect->connect)
更新初回マウントで実行回数違うってどゆこと?ずっと2回実行されるんちゃうの?
https://ja.react.dev/learn/lifecycle-of-reactive-effects#how-react-verifies-that-your-effect-can-re-synchronize

初回マウントだけ2回って訳じゃなかったよな?毎回2回実行されるのがStrict Modeだった気がするが...。

とりあえず事実として

  • 初回マウントではuseEffect内部の処理は2回実行される
  • 依存配列が変更されたときはuseEffect内部の処理は1回しか実行されない

ということは暗記しておくか。なぜその挙動になるのかは謎なので後に理解するとして。

Yug (やぐ)Yug (やぐ)

とにかく知りたいのは、strict modeは内部で何をやっているのか。
特に、strict modeによる2回目の実行では、どこまで状態がやりなおしになるのか、どこからやり直すのか、というところ。

依存配列の値が変更された直後の状態に戻るのであれば、再度useEffectはそれを検知してuseEffect内部の処理は2回実行されるはず、というのが自分の予想だったのに、それが違うということがわかったので、「じゃあどこまで戻るん?どっからやり直してるん?」が知りたい。

1回しか実行されなかったという結果から逆算して「どこからやり直しているのか」の仮説を立てるならば...

「依存配列の値が変更され、それに伴う再レンダーが終了し、変更された値を反映したjsx出力が作られ、その直後のuseEffectが終わった後」からやり直し(2回目のコンポーネント実行)がされるのがstrict modeである、という仮説はどうだ?

ここで言う「依存配列の値が変更され、それに伴う再レンダーが終了し、変更された値を反映したjsx出力が作られ、その直後のuseEffect」が1回目のuseEffectそのものなので、すなわちstrict modeによる2回実行は初回マウントを除くとuseEffectには何も影響を与えないということなのだろうか?

つまり「1回目のuseEffectが終わった後、1回目のuseEffectの直後からやり直すという意味の無い状態が発生する」という仮説。

しかし初回マウントに限って言うとなぜか2回実行された。その結果から逆算して、2回目の実行はどこからやり直しているのかの仮説を立てるならば...

  • 依存配列の値が変更した時点
  • それに伴う再レンダーが実行した時点
  • 変更を反映したjsxの出力が完了した時点

の3つのうちどこかまで戻る、という感じではないだろうか。

なのでそのあと再度useEffectが変更を検知し、2度目のuseEffect内のconsoleが出力される、という特殊ケースが起こる流れ。

Yug (やぐ)Yug (やぐ)

というかクリーンアップ処理書いてなかったから書いたらどうなるかも試してみるか

Yug (やぐ)Yug (やぐ)

やっぱり同じ。初回のみ2回実行(effect->cleanup/effect)なのに更新時は1回実行(cleanup/effect)だなぁ。

export default function App() {
    const [toggle, setToggle] = useState(false);

    useEffect(() => {
        console.log('effect');

        return () => console.log('clean up');
    }, [toggle]);

    return <button onClick={() => setToggle((prev) => !prev)}>トグル</button>;
}
Yug (やぐ)Yug (やぐ)

ずっと一貫して2回実行されるはずでは?と思うんだが...

つまり初回は1回目のレンダーでeffect->2回目のレンダーでcleanup/effect
更新時は1回目のレンダーでcleanup/effect->2回目のレンダーでcleanup/effect

というのが妥当だと思うのだが、なぜそうなっていないんだろう

Yug (やぐ)Yug (やぐ)

ほう、やはりマウント時のみ2回実行されるのがuseEffectであるという事実認識は正しそう。

安全でないライフサイクルを検知するため、開発モードで起動するとマウントを意図したuseEffect()が二度実行されるという挙動となります

https://ics.media/entry/200310/#2022年〜-react-18で並行レンダリング

なので気になるのは2点

  1. なんで2回実行されるのマウント時のみなん?更新時もアンマウント時も、つまりずっとい一貫して2回実行されるべきでは?
  2. マウント時のみ2回実行、というのを表す内部実装があるか確認したい
  3. useEffectの依存配列更新において、renderが2回実行されるが2回目のrenderではuseEffect内のコードを実行しない、というのを表す内部実装があるか確認したい
Yug (やぐ)Yug (やぐ)

1に対して:

...もしや!!「マウント時のみチェックすればロジックが純粋に保たれているかどうかはわかるのでそれで十分だろ。ロジックは変わらないんだから」ということでは??この仮説正しそう(勝手な期待)

Yug (やぐ)Yug (やぐ)

3に対して:

もしかして、2回目のrenderでは実行をスキップするとかそういう話ではなくて、そもそも1回目のrender->エフェクトで依存配列の変更に伴う処理は終了したと見なして、2回目のrenderでは依存配列の変更はもう起きていない、つまり何も変わっていないよね、だから当然エフェクトは呼び出されすらしないよね、的な感じか?

いやでもstateは2回の実行それぞれでリセットされ、変更前から変更後へ、という流れを2回行うはず。それがrender2回セットの挙動のはず。ならばそれに伴ってエフェクトが2回呼び出されるのは必然。しかし2回目は実行はされない。なのでスキップするという処理を内部実装で書いているはず

つまり以下のようなことを内部実装でやっているんじゃないかという予想

let isMounted = false;
let renderCount = 0;

// 初回マウント時は問答無用でエフェクトを2回実行
if (!isMounted) {
    effect();  // エフェクトを実行
    return;
}

// 更新時、1回目のrenderのみエフェクトを実行
// つまりStrict Modeによる2回目のrenderは無視する
if (renderCount === 0) {
    effect();  // エフェクト内の処理を実行
    renderCount++;
    return;
}
Yug (やぐ)Yug (やぐ)

んん...?いやでもよく考えたら「マウント時のみ2回実行でチェックすればええやろ」理論ならrenderだって2回実行するのはマウント時のみでええやん、になってしまうはず。でもそれはおかしい。

開発中にコードを変えてしまう可能性はあるので、常に2回実行され続けるのが理想なはず。
例えば途中でuseEffectのクリーンアップを誤って消してしまったらそれがおかしいということに気付けない。

なんでエフェクトが2回実行されるのはマウント時のみなんだろう(なんで更新時に1回しか実行されないんだろう)

Yug (やぐ)Yug (やぐ)

ん、いやまさか...

それがおかしいということに気付けない

という前提がそもそも間違っているかも?

React側からすると、「いや、それは気付けるよ。よく見ろ」ということなのかもしれない。

つまり、前回のエフェクトのログと比較して、今回のエフェクトのログを見たときに、クリーンアップ結果が出力されておらずエフェクト処理自体のログしか表示されないので、その時点でおかしいとユーザーが気付く、という前提になっているのでは?

Yug (やぐ)Yug (やぐ)

んで、じゃあrenderも1回実行だけで気付けるやろ、という反論は成り立たない。

renderはエフェクトと違って2回実行しないと異変に気付けないから。

ということか!確かにそうだぞ。renderは出力結果だけが出てくるので異変に気付けないが、エフェクトはそもそもクリーンアップは絶対にログに出力されるはずという前提があるので、それが出てこない時点で異変に気付ける。

つまりエフェクトは1回実行するだけでそこにクリーンアップらしきログがでてこなければ「あ、おかしい」と気付ける!ので、1回実行だけで良い。

だが、マウント時は初めての実行なのでクリーンアップを書いたとしてもクリーンアップを走らせることができない。ので、余分に1回実行している。マウント時のみ。

Yug (やぐ)Yug (やぐ)

ただすごく気になるのは、この「マウント時のみ2回実行。あとは1回(クリーンアップとセットアップのセットを1回)のみ。」という事実を公式ドキュメントがどこにも記述していないことなんだよな...。

例えばこの記述、適切ではない気がする。

Strict Mode がオンの場合、React は開発中にすべてのエフェクトに対して 追加で 1 回、セットアップ+クリーンアップのサイクルを実行します

追加で1回実行するのは「開発中」ではなく「マウント時のみ」のはずなんだが...。
https://ja.react.dev/reference/react/StrictMode#fixing-bugs-found-by-re-running-effects-in-development

Yug (やぐ)Yug (やぐ)

ん、そういえばアンマウント時はcleanupが1回だけ実行される感じか?検証する

export default function App() {
    const [val1, setVal1] = useState<number>(0);
    const [val2, setVal2] = useState<number>(0);
    const [toggle, setToggle] = useState<boolean>(true);

    const handleToggle = () => setToggle(prev => !prev);
    const handleVal1Increment = () => setVal1(prev => prev + 1);
    const handleVal2Increment = () => setVal2(prev => prev + 1);

    return (
        <>
            <h1>親だよ</h1>
            <button onClick={handleToggle}>toggle</button>
            <button onClick={handleVal1Increment}>val1を+1</button>
            <button onClick={handleVal2Increment}>val2を+1</button>

            {toggle ? (
                <ChildOne val1={val1} />
            ) : (
                <ChildTwo val2={val2} />
            )}
        </>
    )
}

function ChildOne({ val1 }: { val1: number }) {
    useEffect(() => {
        console.log('1がeffect');
        return () => console.log('1がclean up');
    }, [val1])

    return (
        <h1>1だよ</h1>
    )
}

function ChildTwo({ val2 }: { val2: number }) {
    useEffect(() => {
        console.log('2がeffect');
        return () => console.log('2がclean up');
    }, [val2])

    return (
        <h1>2だよ</h1>
    )
}

やはりcleanupが1回のみ実行されるだけだな

Yug (やぐ)Yug (やぐ)

さて、とりあえず仮説は立てられたので、これが合ってるか内部実装を見に行こう。
んで、2度目のrenderを更新時のエフェクトはどのように無視しているのかも気になるので見てみたい。