How React hooks work - in depth

React hooks image

Intro

In simple cases, React Hooks will magically do exactly what you meant for, but in other cases, their behavior can feel inconsistent and unpredictable. the next article will try to deeply explain and demonstrate React hooks behavior.

The article consisted of four main sections:

Which of you that will finish reading the article to the end, and will really understand the latest example, will no longer be surprised by unexpected problems when using hooks in components with a complicated lifecycle.

The article is not for starters, and I will assume that you have some experience with React and React hooks.

article mirrors

read in your preferred platform:

For best readability and for most updated version I would strongly recommend read from Home page. Comments and questions can be left on your preferred platform.

Definitions

If you are not a React expert, It is strongly recommended to read the definitions section. You can start from the example section and then return to this section later if something is not clear.

the more important definitions here are: render, update, React hook and phase.

Note - These definitions were summarized by me and may not be accurate, but they are sufficient to understand the rest of the article.

React Hooks

There 2 types of React hooks:

Render cycle

these are the phases of a render:

FC body:

effects:

after these phases, the ‘render’ cycle is completed and then ReactDOM will do the ‘commit’ step which basically just saying updating the browser’s DOM based on the virtual DOM created by the render step. the ‘commit’ phase is not relevant for the purpose of this article.

cleanup effects:

before each effect is fired a cleanup function is fired(if scheduled). the cleanup effects are:

Note - cleanup effect will never fire on the first render(because there is no prior effect to cleanup from), and when component unmount only the cleanup effect are fired.

Render cycle summary:

per render cycle: Each effect fires the most 1 times, excluding update call which fires at least once.

The effects are fired in this order(excluding the first render), and only if was scheduled:

  1. updateCall - may be called several times for a single render, and will occur one after another before any effect!
  2. useLayoutEffects cleanup
  3. useLayoutEffects
  4. useEffects cleanup
  5. useEffects

the order on first render:

  1. updateCall (possibly multiple times)
  2. useLayoutEffects
  3. useEffects

the order when component unmount(this is not exactly a ‘render’):

  1. useLayoutEffect cleanup
  2. useEffect cleanup

the AllPhases example demonstrates this very well.

Super Important Notes

Examples

The latest examples are the most interesting, but in order to understand them one has to understand the first examples first, so make sure follow the examples one after another.

important Note - each line of the code that will come next are part of the tutorial, even the comments. read them all to follow along.

Basic

OK enough words. see the next example.

const Basic = () => {
    // log function helper
    // this will help up follow the component phase cycle
    const render = useRef(0);
    const call = useRef(0);
    const consoleState = () => `{call:${call.current},render:${render.current}}`;
    const log = (...args) => console.log(...args, consoleState());
    // update phase counts
    call.current += 1;
    useEffect(() => {
        render.current += 1;
    });

    //logic
    useEffect(() => {
        log("mount has finished");
    }, []);
    useEffect(() => {
        log("render has finished");
    });
    log("update call");
    return <div/>;
};

what order of logs would you expect when the component mounts? think for a second and replace the ‘?’:

/**
 * expected logs:
 *    update call           {call:?,render:?}
 *    mount has finished    {call:?,render:?}
 *    render has finished   {call:?,render:?}
 */
Reveal answer

well, the order is:

/**
 * expected logs:
 *    update call {call:1,render:0}
 *    mount has finished {call:1,render:1}
 *    render has finished {call:1,render:1}
 */

as we explained earlier, the function body fire first and then the effects.

Code Sandbox

BasicReverse

what will happen if we will replace the effects, does the order will change?

const BasicReverse = () => {
    // log function helper
    // ...
    // logic
    useEffect(() => {
        log("render has finished");
    });
    useEffect(() => {
        log("mount has finished");
    }, []);
    log("update call");
    return <div/>;
};

well, the order does change, and will be:

/**
 * expected logs:
 *    update call {call:1,render:0}
 *    render has finished {call:1,render:1}
 *    mount has finished {call:1,render:1}
 */

this is because effect hooks from the same type(here useEffect) are scheduled by React for the same phase and will be executed in the order of declaration, this is a common mistake to think that useEffect with an empty dependency array will fire on the mount and on a different phase from useEffect with no dependency array.

Code Sandbox

useLog

now let’s create a log helper hook useLog that will let us keep track of the component phase for later examples:

const useLog = (componentName = "", effect = useEffect) => {
    // keep track of phase
    const render = useRef(0);
    const call = useRef(0);

    const consoleState = () => `{call:${call.current},render:${render.current}}(${componentName})`;
    const log = (...args) => console.log(...args, consoleState());

    effect(() => {
        render.current += 1;
    });
    call.current += 1;

    return log;
};

render.current and call.current will ‘tick’ at the same rate of the parent component because of hooks natures.\ This is simplified useLog, you will see different useLog hook in the UseLog.js file which includes some logic for monitoring execution time.

and usage:

const Basic = () => {
    const log = useLog();
    useEffect(() => {
        log("finished render");
    });
    return <div/>;
};

/**
 * expected logs:
 *    finished render {call:1,render:1}()
 */
Code Sandbox

Unmount

if we will trigger unmount after mount the logs order will be:

const BasicUnmount = () => {
    const log = useLog();
    useEffect(() => {
        log("mount");
        return () => log("unmount");
    }, []);
    useEffect(() => {
        log("render");
        return () => log("un-render");
    });
    log("update call");
    return <div>asd</div>;
    /**
     * expected logs:
     *    update call {call:1,render:0}
     *    mount {call:1,render:1}
     *    render {call:1,render:1}
     *    unmount {call:1,render:1}
     *    un-render {call:1,render:1}
     */
};

when a component goes through unmounting step - the update phase does not happen, only the effect fire, in the order of declaration.

Code Sandbox

Effect vs LayoutEffect

useLayoutEffect is executed, then useEffect:

const EffectVsLayoutEffect = () => {
    const log = useLog("effects", undefined, "abs");
    useEffect(() => {
        log("useEffect!");
    });
    useLayoutEffect(() => {
        log("useLayoutEffect!");
    });
    return <div/>;
    /**
     * expected logs:
     * useLayoutEffect! {call:1,render:0}(effects) 164.565ms
     * useEffect! {call:1,render:1}(effects) 174.52ms
     */
};
Code Sandbox

AllPhases

This demonstrates all the different phases combined. after mount another dumy re-render is scheduled, we will use absolute timing for this example to see when each phase is executed:

const AllPhases = () => {
    const log = useLog("AllPhases", useEffect, "abs");

    const [, setState] = useState({});
    const forceRender = () => setState({});

    useEffect(() => {
        log("useEffect");
        return () => log("useEffect cleanup");
    });
    useLayoutEffect(() => {
        log("useLayoutEffect");
        return () => log("useLayoutEffect cleanup");
    });
    log("update");

    // fire only on mount
    useEffect(() => {
        log("component fully mounted and render cycle ended. now scheduling another render...");
        forceRender();
        return () => log("unmount cleanup");
    }, []);

    return <div/>;
    /**
     * expected logs:
     *    update {call:1,render:0}(AllPhases) 146.36ms
     *    useLayoutEffect {call:1,render:0}(AllPhases) 150.345ms
     *    useEffect {call:1,render:1}(AllPhases) 159.425ms
     *    component fully mounted and render cycle ended. now scheduling another render... {call:1,render:1}(AllPhases) 159.71ms
     *    update {call:2,render:1}(AllPhases) 162.05ms
     *    useLayoutEffect cleanup {call:2,render:1}(AllPhases) 163.75ms
     *    useLayoutEffect {call:2,render:1}(AllPhases) 164.34ms
     *    useEffect cleanup {call:2,render:1}(AllPhases) 167.435ms
     *    useEffect {call:2,render:2}(AllPhases) 168.105ms
     *
     * when unmount(move to other page for example):
     *    useLayoutEffect cleanup {call:2,render:2}(AllPhases) 887.375ms
     *    useEffect cleanup {call:2,render:2}(AllPhases) 892.055ms
     *    unmount cleanup {call:2,render:2}(AllPhases) 892.31ms
     */
};

this example deeply demonstrates all the different possible phases while a component renders. make sure you understand that before proceeding to the next examples.

Code Sandbox

UpdateCycle

when you set a state while in the update phase another update phase will be scheduled by React. let’s try to force React to trigger 10 update calls before rendering.

const UpdateCycle = () => {
    const log = useLog("UpdateCycle");
    const [, setState] = useState({});
    const forceUpdate = () => setState({});
    const updateCalls = useRef(0);

    const HandleClick = () => {
        updateCalls.current = 0;
        forceUpdate();
    };
    updateCalls.current += 1;
    if (updateCalls.current < 10) forceUpdate();

    useEffect(() => {
        log("render");
    });
    log("update");

    return (
        <div style={boxStyle} onClick={HandleClick}>
            click
        </div>
    );
    /**
     * update {call:1,render:0}(UpdateCycle) 0.33ms
     * update {call:2,render:0}(UpdateCycle) 0.17ms
     * update {call:3,render:0}(UpdateCycle) 0.03ms
     * update {call:4,render:0}(UpdateCycle) 0.025ms
     * update {call:5,render:0}(UpdateCycle) 0.045ms
     * update {call:6,render:0}(UpdateCycle) 0.04ms
     * update {call:7,render:0}(UpdateCycle) 0.03ms
     * update {call:8,render:0}(UpdateCycle) 0.02ms
     * update {call:9,render:0}(UpdateCycle) 0.03ms
     * update {call:10,render:0}(UpdateCycle) 0.015ms
     * render {call:10,render:1}(UpdateCycle) 0.245ms
     */
};

as we can see, we forced React to re-call the function body 10 times before performing the render. we can also notice that the render phase occurred 0.245ms after the last update call.

Code Sandbox

RenderCycle

Ok, so we saw what happens when we update the state while in the update phase, but what happens if we try to update the state when we are no longer in the update phase? well, React will schedule an entire re-render cycle for the component. each render cycle will also include at least one update call.
let’s force 5 render cycles:

const RenderCycle = () => {
    const log = useLog("RenderCycle");
    const [, setState] = useState({});
    const forceRender = () => setState({});
    const renderCalls = useRef(0);

    const HandleClick = () => {
        renderCalls.current = 0;
        forceRender();
    };

    useEffect(() => {
        renderCalls.current += 1;
        if (renderCalls.current < 5) forceRender();
        log("render");
    });
    log("update");

    return (
        <div style={boxStyle} onClick={HandleClick}>
            click
        </div>
    );
    /**
     * update {call:1,render:0}(RenderCycle) 0.365ms
     * render {call:1,render:1}(RenderCycle) 0.33ms
     * update {call:2,render:1}(RenderCycle) 0.26ms
     * render {call:2,render:2}(RenderCycle) 0.315ms
     * update {call:3,render:2}(RenderCycle) 0.12ms
     * render {call:3,render:3}(RenderCycle) 0.25ms
     * update {call:4,render:3}(RenderCycle) 0.07ms
     * render {call:4,render:4}(RenderCycle) 0.495ms
     * update {call:5,render:4}(RenderCycle) 0.055ms
     * render {call:5,render:5}(RenderCycle) 0.135ms
     */
};

we can see that each render cycle comes with an update call.

Code Sandbox

CombinedCycle

now lets say we want 5 update calls for each render. let’s force 3 renders:

const CombinedCycle = () => {
    const log = useLog("CombinedCycle");
    const [, setState] = useState({});
    const forceUpdate = () => setState({});
    const updateCalls = useRef(0);
    const renderCalls = useRef(0);

    const HandleClick = () => {
        updateCalls.current = 0;
        renderCalls.current = 0;
        forceUpdate();
    };
    updateCalls.current += 1;
    if (updateCalls.current < 5) forceUpdate();

    useEffect(() => {
        renderCalls.current += 1;
        if (renderCalls.current < 3) forceUpdate();
        updateCalls.current = 0;
        log("render");
    });
    log("update");

    return (
        <div style={boxStyle} onClick={HandleClick}>
            click
        </div>
    );
};
/**
 * update {call:1,render:0}(CombinedCycle) 0.085ms
 * update {call:2,render:0}(CombinedCycle) 0.17ms
 * update {call:3,render:0}(CombinedCycle) 0.03ms
 * update {call:4,render:0}(CombinedCycle) 0.025ms
 * update {call:5,render:0}(CombinedCycle) 0.03ms
 * render {call:5,render:1}(CombinedCycle) 0.29ms
 * update {call:6,render:1}(CombinedCycle) 0.03ms
 * update {call:7,render:1}(CombinedCycle) 0.095ms
 * update {call:8,render:1}(CombinedCycle) 0.02ms
 * update {call:9,render:1}(CombinedCycle) 0.04ms
 * update {call:10,render:1}(CombinedCycle) 0.025ms
 * render {call:10,render:2}(CombinedCycle) 0.08ms
 * update {call:11,render:2}(CombinedCycle) 0.055ms
 * update {call:12,render:2}(CombinedCycle) 0.085ms
 * update {call:13,render:2}(CombinedCycle) 0.025ms
 * update {call:14,render:2}(CombinedCycle) 0.03ms
 * update {call:15,render:2}(CombinedCycle) 0.03ms
 * render {call:15,render:3}(CombinedCycle) 0.085ms
 */
Code Sandbox

MultipleComponents

Let’s combine the last 3 examples into the common parent.

import UpdateCycle from "./UpdateCycle";
import RenderCycle from "./RenderCycle";
import CombinedCycle from "./CombinedCycle";

const Example = () => (
    <>
        <UpdateCycle/>
        <RenderCycle/>
        <CombinedCycle/>
    </>
);

now stop. think. what would you expect? does each component will go through her own update-render phases or maybe the update calls will occur one after another and then the effects one after another?

Reveal answer

the entire tree goes through the phase of the update, and only then the effects are fired.

/**
 * update {call:1,render:0}(UpdateCycle) 0.505ms
 * update {call:2,render:0}(UpdateCycle) 0.22ms
 * update {call:3,render:0}(UpdateCycle) 0.03ms
 * update {call:4,render:0}(UpdateCycle) 0.035ms
 * update {call:5,render:0}(UpdateCycle) 0.075ms
 * update {call:6,render:0}(UpdateCycle) 0.05ms
 * update {call:7,render:0}(UpdateCycle) 0.04ms
 * update {call:8,render:0}(UpdateCycle) 0.04ms
 * update {call:9,render:0}(UpdateCycle) 0.045ms
 * update {call:10,render:0}(UpdateCycle) 0.025ms
 * update {call:1,render:0}(RenderCycle) 0.035ms
 * update {call:1,render:0}(CombinedCycle) 0.065ms
 * update {call:2,render:0}(CombinedCycle) 0.06ms
 * update {call:3,render:0}(CombinedCycle) 0.065ms
 * update {call:4,render:0}(CombinedCycle) 0.045ms
 * update {call:5,render:0}(CombinedCycle) 0.04ms
 * render {call:10,render:1}(UpdateCycle) 0.15ms
 * render {call:1,render:1}(RenderCycle) 0.33ms
 * render {call:5,render:1}(CombinedCycle) 0.17ms
 * update {call:2,render:1}(RenderCycle) 0.295ms
 * update {call:6,render:1}(CombinedCycle) 0.045ms
 * update {call:7,render:1}(CombinedCycle) 0.045ms
 * update {call:8,render:1}(CombinedCycle) 0.04ms
 * update {call:9,render:1}(CombinedCycle) 0.06ms
 * update {call:10,render:1}(CombinedCycle) 0.04ms
 * render {call:2,render:2}(RenderCycle) 0.145ms
 * render {call:10,render:2}(CombinedCycle) 0.145ms
 * update {call:3,render:2}(RenderCycle) 0.055ms
 * update {call:11,render:2}(CombinedCycle) 0.05ms
 * update {call:12,render:2}(CombinedCycle) 0.085ms
 * update {call:13,render:2}(CombinedCycle) 0.03ms
 * update {call:14,render:2}(CombinedCycle) 0.015ms
 * update {call:15,render:2}(CombinedCycle) 0.02ms
 * render {call:3,render:3}(RenderCycle) 0.125ms
 * render {call:15,render:3}(CombinedCycle) 0.075ms
 * update {call:4,render:3}(RenderCycle) 0.06ms
 * render {call:4,render:4}(RenderCycle) 0.135ms
 * update {call:5,render:4}(RenderCycle) 0.025ms
 * render {call:5,render:5}(RenderCycle) 0.06ms
 */
Code Sandbox

phew! that was tough. if you read and understand everything to this point you can confidently say that you understand React hook’s nature.

Component with complicated lifecycle

so why do we need to understand all of this? well, in simple cases you don’t, but when dealing with a component with a complicated lifecycle you can sometimes get confused by the component’s behavior. an example of such component will be react-xarrow which needs to trigger callback on different phases to get the right dimensions and activate animations callbacks on different phases. by writing this lib I learned how hooks really behave, so I could optimize the render cycle and improve performance by far.
Pro-tip: with components with complicated lifecycle you would probably want to use many times useRef and not useState! this way you don’t force re-renders during updates and this solving problems with state variables that dependent on other state variables which will be ‘ready’ only on next render.

Recap

That’s it! you now understand what really going on when you asks React to update some state in some component.

If you liked this tutorial make sure to like it and share it! thank you for reading until the end!