Different batching behavior between React event and useEffect

68 views Asked by At

As far as I know, React 18 will auto-batch state updates to reduce re-render (including updates inside of timeouts, promises...), e.g.

    function handleClick() {
        setCount(c => c + 1); // Does not re-render yet
        setFlag(f => !f); // Does not re-render yet
        // React will only re-render once at the end (that's batching!)
    }
    fetch(/*...*/).then(() => {
        setCount(c => c + 1);
        setFlag(f => !f);
        // React will only re-render once at the end (that's batching!)
    })

but batch will not applies to different timeouts/promises, e.g.

    const fetchData = async () => {
        const resA = await fetch('...').then(res => res.json()).then(data => data);
        setA(resA);
        const resB = await fetch('...').then(res => res.json()).then(data => data);
        setB(resB);
        // this will end up having two re-renders
    }

This is why I usually intentionally batch state updates like this:

    const fetchData = async () => {
        const resA = await fetch('...').then(res => res.json()).then(data => data);
        const resB = await fetch('...').then(res => res.json()).then(data => data);
        setA(resA);
        setB(resB);
        // this works, only one re-render
    }

but I recently came upon this inconsistent batching behavior between React event and useEffect, here's the experiment code:codesandbox Note:strictMode is commented out.

    // ContextProvider.js
    const initialState = {
        a: null,
        b: null,
        c: null,
        d: null,
        e: null
    }
    const testReducer = (state, action) => {
        switch(action.type) {
            case 'capitalA' : {
                return {
                    ...state, a: "A"
                }
            }
            case 'capitalB' : {
                return {
                    ...state, b: "B"
                }
            }
            case 'capitalC' : {
                return {
                    ...state, c: "C"
                }
            }
            case 'capitalD' : {
                return {
                    ...state, d: "D"
                }
            }
            case 'capitalE' : {
                return {
                    ...state, e: "E"
                }
            }
            default : {
                return state;
            }
        }
    }
    // Test.js
    export default function Test() {
        const state = useStateContext();
        const dispatch = useDispatchContext();
        console.log(state)

        const asyncCapitalFunc = async letter => {
            dispatch({type: `capital${letter}`});
        };

        const batchTest = async () => {
            await asyncCapitalFunc('A');
            await asyncCapitalFunc('B');
        };

        return (
            <>
            <p>{state.count}</p>
            <button onClick={() => batchTest()}>Click</button>
            </>
        )
    };

results:

    const batchTest = async () => {
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
        dispatch({type: 'capitalC'});
    };

The above code: One click causes three re-renders, which is as expected. click result 1

    const batchTest = async () => {
        dispatch({type: 'capitalC'});
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
    };

The above code: One click causes two re-renders, also as expected, the first await call is batched together with the regular dispatch above it. click result 2

    const batchTest = async () => {
        dispatch({type: 'capitalC'});
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
        await asyncCapitalFunc('D');
        await asyncCapitalFunc('E');
    };

The above code: One click causes four re-renders, still as expected. click result 3

    const batchTest = async () => {
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
        dispatch({type: 'capitalC'});
        await asyncCapitalFunc('D');
        await asyncCapitalFunc('E');
    };

The above code: One click causes four re-renders, still as expected. click result 4

----------------------now let’s switch the same code to Effect------------------

    import { useStateContext, useDispatchContext } from "./ContextProvider"
    import { useEffect } from "react";

        export default function Test() {
        const state = useStateContext();
        const dispatch = useDispatchContext();
        console.log(state)

        useEffect(()=> {
            const asyncCapitalFunc = async letter => {
                dispatch({type: `capital${letter}`});
            };

            const batchTest = async () => {
                await asyncCapitalFunc('A');
                await asyncCapitalFunc('B');
            };
        batchTest()
        }, [dispatch]);

        return (
            <>
            <p>{state.count}</p>
            </>
        )
    };

results:

    const batchTest = async () => {
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
        dispatch({type: 'capitalC'});
    };

The above code: In addition to the initial render, there’re two re-renders, and the second await call is batched together with the dispatch below it, which is not the same as what happens in a click event. Effect result1

    const batchTest = async () => {
        dispatch({type: 'capitalC'});
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
    };

The above code: In addition to the initial render, there’re two re-renders, this time it behaves the same as the click event.

    const batchTest = async () => {
        dispatch({type: 'capitalC'});
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
        await asyncCapitalFunc('D');
        await asyncCapitalFunc('E');
    };

The above code: Two re-renders, the first await call is batched with the dispatch above it which is as expected, but why the remaining three await calls batched together? Effect result3

    const batchTest = async () => {
        await asyncCapitalFunc('A');
        await asyncCapitalFunc('B');
        dispatch({type: 'capitalC'});
        await asyncCapitalFunc('D');
        await asyncCapitalFunc('E');
    };

The above code: I’m getting confused, why the await calls below "A" are all batched together… Effect result3

Hope someone can explain why this quirky behavior happens in Effect, thank you.

0

There are 0 answers