Open8

jsx出力のあとすぐにエフェクトが走らないのはなぜ?

Yug (やぐ)Yug (やぐ)

このコードを実行すると...

export default function App() {
    const [val, setVal] = useState(0);
    console.log('valは', val);

    let num = 100;
    num++;
    console.log('numは', num);

    useEffect(() => {
        setVal(prev => prev + 1);
        console.log('---effectに入った---');
        console.log('effect内のvalは', val);
        console.log('effect内のnumは', num);
    }, [num])

    return (
        <>
            <h1>hello</h1>
            val: {val}
            {console.log('jsx')}
        </>
    )
}

結果はこうなる↓

Yug (やぐ)Yug (やぐ)

「useEffectってjsx出力の後に実行されるんちゃうの?2回の実行が全部終わった後にエフェクトが走り出すのってエフェクトの基本原則から違反してない?それともそもそもエフェクトの実行タイミングってそういうものだっけ?」という疑問。

Yug (やぐ)Yug (やぐ)

結果・疑問・仮説をとりあえず立ててみるか

  • 結果

jsx出力が計2回出力されるまで待って、そのあとにuseEffectはまとめて2回実行される

  • 疑問1

じゃあなぜjsx出力の後すぐに実行されない仕組みにしたんだろう?

  • 仮説1

ちょっとずつ変わっていくのではなくて一気に変わった方が差が見やすいとかそこらへんの理由がありそうな予感。0->1->2と表示するんじゃなくて0->2と表示させた方がわかりやすくね?みたいな思想なんじゃないかな?もし0->1->2になるようにしてしまうと、イメージとしては以下の右の画像のような感じになってしまう。それよりは左の画像のように一気に0->2になった方がいろいろと都合が良いんじゃないかな

Yug (やぐ)Yug (やぐ)

うーん、なるほど...。

まず、実際の挙動は一気に0から2になるので、「処理の流れが見やすい」という訳ではない。「え?いきなり2になったやん、何が起きた?」ってなると思うので。

ただ、「値を扱いやすい」というメリットは確かに強いんだろうなと推測はできる。
流れるようにstateがどんどん変わってしまう訳ではなく「スナップショット」として振舞うので扱いやすいんだろうなぁ。

Yug (やぐ)Yug (やぐ)

いや、試してみたらそんなレベルの話ではなかった。扱いやすいというのはそうだが、このuseEffectの「トロい実行」の仕組みによって無駄な再レンダーを防げているという素晴らしい事実を発見した。

さっきのコードを実行した場合、Reactの実際の挙動はこんな感じになるけど...

// Reactの実際の挙動
export default function App() {
    // 初回マウントでrender + 2 = 2
    console.log('valは', 0);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 1

    console.log('valは', 0);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 0

    // 初回マウントのみeffectは2回実行されるという仕様によるeffect発火1回目
    console.log('---effectに入った---');  // renderのキューイング
    console.log('effect内のvalは', 0);
    console.log('effect内のnumは', 101);

    // 仕様によるeffect発火2回目
    console.log('---effectに入った---');  // renderのキューイング
    console.log('effect内のvalは', 0);
    console.log('effect内のnumは', 101);

    // ここで最終的にキューのバッチ処理の結果としてrenderは2回しか走らない
    console.log('valは', 2);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 1

    console.log('valは', 2);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 0
}

そうではなかった場合はこんな感じになるはず

// もしjsx出力直後にeffectが走ってしまう仕様だった場合の挙動
export default function App() {
    // render + 2 = 2
    console.log('valは', 0);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 1

    // 初回マウントのみeffectは2回実行されるという仕様によるeffect発火1回目
    console.log('---effectに入った---');  // renderのキューイング
    console.log('effect内のvalは', 0);
    console.log('effect内のnumは', 101);

    // キューイングされていたrenderがここで既に走ってしまう
    // render + 2 = 3
    console.log('valは', 1);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 2

    // 仕様によるeffect発火2回目
    console.log('---effectに入った---');  // renderのキューイング
    console.log('effect内のvalは', 1);
    console.log('effect内のnumは', 101);

    // キューイングされていたrenderが走る
    // render + 2 = 4
    console.log('valは', 2);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 3

    console.log('valは', 2);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 2

    console.log('valは', 2);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 1

    console.log('valは', 2);
    console.log('numは', 101);
    console.log('jsx');  // render - 1 = 0
}

これによってわかるのが、Reactの実際の挙動の方がレンダーが1回分(今回は開発モードなので2回分)少なく済んでいるということ!

なるほどなぁ...。キューイングするという段階はなるべく連続して行われるのが理想ということね!
そうじゃないとキューイング->実行、キューイング->実行が断絶してしまい、その断絶の数だけレンダーが増えていってしまうという背景がある。

だからuseEffectが連続で実行されるようにしてるのか。「jsx出力の後」ではなく「jsx出力が2回行われた後」にuseEffectが連続で実行されるようにしてあるんだな。

Yug (やぐ)Yug (やぐ)
  • 疑問2

疑問1を踏まえた上での更なる疑問なんだが...
useEffectの実行タイミングは画面描画の後だよね?つまりjsx出力の後。しかし実際の挙動だとその原則を外れてしまっていないか?
実際の挙動は、

レンダー->jsx出力->レンダー->jsx出力->effect1->effect2->レンダー->jsx出力->レンダー->jsx出力

なので、やはりjsx出力の直後にeffectが発火していないという状態。これは原則と違うと思うんだがこれをどう説明すれば良いんだろう。

  • 仮説2

strict modeの場合、「レンダー&jsx出力」の2回セットが発生するので、その2回セット含めて「1回のjsx出力」みたいな感じで捉えてるのかな?それなら多少無理やり感はあるが矛盾が解消される。

「本番環境では1回しか実行されないんだから開発環境での2回セットも1回のみとしてカウントするよ。そうしないと開発環境と本番環境で挙動の一貫性が保たれないじゃん」みたいな感じ?

Yug (やぐ)Yug (やぐ)

良さげな一文

エフェクトは、コミットの最後に、画面が更新された後に実行されます

https://ja.react.dev/learn/synchronizing-with-effects#what-are-effects-and-how-are-they-different-from-events

コミットの後ではなくコミットの最後ということは、「エフェクトの実行タイミングはコミットフェーズ」という理解で良いか?でも「画面が更新された後」かぁ、ややこしいなぁどうなんだろう