Search code examples
javascriptreactjsreact-hooksdependenciesusecallback

Children useCallback dependency hell


From what I understand you use useCallback to prevent rerendering so I've been using it in every function and my spider senses are telling me it already sounds bad.

But the story doesn't ends there, since I've been using it everywhere I'm now passing dependencies to all my child components that they shouldn't need to worry about like in the following example :

EDIT // SANDBOX: https://codesandbox.io/s/bold-noether-0wdnp?file=/src/App.js

Parent component (needs colorButtons and currentColor)

const ColorPicker = ({onChange}) => {
    const [currentColor, setCurrentColor] = useState({r: 255, g:0, b: 0})
    const [colorButtons, setColorButtons] = useState({0: null})

    const handleColorButtons = useCallback((isToggled, id) => {
        /* code that uses colorButtons and currentColor */
    }, [colorButtons, currentColor])

    return <div className="color-picker">
        <RgbColorPicker color={currentColor} onChange={setCurrentColor} />
        <div className="color-buttons">
            {
                Object.entries(colorButtons).map(button => <ColorButton
                    //...
                    currentColor={currentColor}
                    onClick={handleColorButtons}
                    colorButtons={colorButtons}
                />)
            }
        </div>
    </div>
}

1st child (needs style and currentColor but gets colorButtons for free from its parent)

const ColorButton = ({currentColor, onClick, id, colorButtons}) => {
    const [style, setStyle] = useState({})

    const handleClick = useCallback((isToggled) => {
        /* code that uses setStyle and currentColor */
    }, [style, currentColor, colorButtons])

    return <ToggleButton
        //...
        onClick={handleClick}
        style={style}
        dependency1={style}
        dependency2={currentColor}
        dependency3={colorButtons}
    >
    </ToggleButton>
}

2nd child (only needs its own variables but gets the whole package)

const ToggleButton = ({children, className, onClick, style, data, id, onRef, ...dependencies}) => {
    const [isToggled, setIsToggled] = useState(false)
    const [buttonStyle, setButtonStyle] = useState(style)

    const handleClick = useCallback(() => {
        /* code that uses isToggled, data, id and setButtonStyle */
    }, [isToggled, data, id, ...Object.values(dependencies)])

    return <button 
        className={className || "toggle-button"} 
        onClick={handleClick}
        style={buttonStyle || {}}
        ref={onRef}
    >
        {children}
    </button>
}

Am I doing an anti-pattern and if so, what is it and how to fix it ? Thanks for helping !


Solution

  • React hook useCallback

    useCallback is a hook that can be used in functional React components. A functional component is a function that returns a React component and that runs on every render, which means that everything defined in its body get new referential identities every time. An exception to this can be accomplished with React hooks which may be used inside functional components to interconnect different renders and maintain state. This means that if you save a reference to a regular function defined in a functional component using a ref, and then compare it to the same function in a later render, they will not be the same (the function changes referential identity between renderings):

    // Render 1
    ...
    const fnInBody = () => {}
    const fn = useRef(null)
    console.log(fn.current === fnInBody) // false since fn.current is null
    fn.current = fnInBody
    ...
    
    
    // Render 2
    ...
    const fnInBody = () => {}
    const fn = useRef(null)
    console.log(fn.current === fnInBody) // false due to different identity
    fn.current = fnInBody
    ...
    

    As per the docs, useCallback returns "a memoized version of the callback that only changes if one of the dependencies has changed" which is useful "when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders".

    To sum up, useCallback will return a function that maintains its referential identity (e.g. is memoized) as long as the dependencies don't change. The returned function contains a closure with the used dependencies and must thus be updated once the dependencies change.

    This results in this updated version of the previous example

    // Render 1
    ...
    const fnInBody = useCallback(() => {}, [])
    const fn = useRef(null)
    console.log(fn.current === fnInBody) // false since fn.current is null
    fn.current = fnInBody
    ...
    
    
    // Render 2
    ...
    const fnInBody = useCallback(() => {}, [])
    const fn = useRef(null)
    console.log(fn.current === fnInBody) // true
    fn.current = fnInBody
    ...
    

    Your use case

    Keeping the above description in mind, let's have a look at your use of useCallback.

    Case 1: ColorPicker

    const handleColorButtons = useCallback((isToggled, id) => {
        /* code that uses colorButtons and currentColor */
    }, [colorButtons, currentColor])
    

    This function will get a new identity every time colorButtons or currentColor changes. ColorPicker itself rerenders either when one of these two are set or when its prop onChange changes. Both handleColorButtons and the children should be updated when currentColor or colorButtons change. The only time the children benefit from the use of useCallback is when only onChange changes. Given that ColorButton is a lightweight component, and that ColorPicker rerenders mostly due to changes to currentColor and colorButtons, the use of useCallback here seems redundant.

    Case 2: ColorButton

    const handleClick = useCallback((isToggled) => {
        /* code that uses setStyle and currentColor */
    }, [style, currentColor, colorButtons])
    

    This is a situation similar to the first case. ColorButton rerenders when currentColor, onClick, id or colorButtons change and the children rerender when handleClick, style, colorButtons or currentColor change. With useCallback in place, the props id and onClick may change without rerendering the children (according to the above visible code at least), all other rerenders of ColorButton will lead to its children rerendering. Again, the child ToggleButton is lightweight and id or onClick are not likely to change more often than any other prop so the use of useCallback seems redundant here as well.

    Case 3: ToggleButton

    const handleClick = useCallback(() => {
            /* code that uses isToggled, data, id and setButtonStyle */
        }, [isToggled, data, id, ...Object.values(dependencies)])
    

    This case is elaborate with a lot of dependencies but from what I see, one way or the other, most of the component props will lead to a "new version" of handleClick and with the children being lightweight components, the argument to use useCallback seems weak.

    So when should I use useCallback?

    As the docs say, use it in the very specific cases when you need a function to have referential equality between renders ...

    • You have a component with a subset of children that are expensive to rerender and that should rerender much less often than the parent component but rerender due to a function prop changing identity whenever the parent rerenders. To me, this use case also signals bad design and I would attempt to divide the parent component into smaller components but what do I know, maybe this is not always possible.

    • You have a function in the body of the functional component which is used in another hook (listed as a dependency) which is triggered every time due to the function changing identity whenever the component rerenders. Typically, you can omit such a function from the dependency array by ignoring the lint rule even if this is not by the book. Other suggestions are to place such a function outside the body of the component or inside the hook that uses it, but there might be scenarios where none of this works out as intended.

    Good to know connected to this is ...

    • A function living outside a functional component will always have referential equality between renders.

    • The setters returned by useState will always have referential equality between renders.

    I said in the comments that you can use useCallback when there is function doing expensive calculations in a component that rerenders often but I was a bit off there. Let's say you have a function that does heavy calculations based on some prop that changes less often than a component rerenders. Then you COULD use useCallback and run a function inside it that returns a function with a closure with some computed value

    const fn = useCallback(
        (
            () => {
                const a = ... // heavy calculation based on prop c
                const b = ... // heavy calculation based on prop c
                return () => { console.log(a + b) }
            }
        )()
    , [c])
    ...
    /* fn is used for something, either as a prop OR for something else */
    

    This would effectively avoid calculating a and b every time the component rerenders without c changing, but the more straightforward way to do this would be to instead

    const a = useMemo(() => /* calculate and return a */, [c])
    const b = useMemo(() => /* calculate and return b */, [c])
    const fn = () => console.log(a + b)
    

    so here the use of useCallback just complicates things in a bad way.

    Conclusion

    It's good to understand more complicated concepts in programming and to be able to use them, but part of the virtue is also to know when to use them. Adding code, and especially code that involves complicated concepts, comes at the price of reduced readability and code that is harder to debug with a lot of different mechanisms that interplay. Therefore, make sure you understand the hooks, but always try to not use them if you can. Especially useCallback, useMemo and React.memo (not a hook but a similar optimization) should, in my opinion, only be introduced when they are absolutely needed. useRef has its very own use cases but should also not be introduced when you can solve your problem without it.


    Good work on the sandbox! Always makes it easier to reason about code. I took the liberty of forking your sandbox and refactoring it a bit: sandbox link. You can study the changes yourself if you want. Here is a summary:

    • It's good that you know and use useRef and useCallback but I was able to remove all the uses, making the code much easier to understand (not only by removing these uses but by also removing the contexts where they are used).

    • Try to work with React to simplify things. I know this is not a hands-on suggestion but the more you get into React, the more you will realize that you can do things co-operating with React, or you can do things your own way. Both will work but the latter will result in more headache for you and everybody else.

    • Try to isolate the scope of a component; only delegate data that is necessary to child components and constantly question where you keep your state. Earlier you had click handlers in all three components and the flow was so complicated I didn't even bother to fully understand it. In my version, there is just one click handler in ColorPicker that is being delegated down. The buttons don't have to know what happens when you click them as long as the click handler takes care of that. Closures and the ability to pass functions as arguments are strong advantages of React and Javascript.

    • Keys are important in React and it's good to see that you use them. Typically, the key should correspond to something that uniquely identifies a specific item. Good to use here would be ${r}_${g}_${b} but then we would only be able to have one sample of each color in the button array. This is a natural limitation but if we don't want it, the only way to assign keys is to assign a unique identifier, which you did. I prefer using Date.now() but some would probably advise against it for some reason. You could use a global variable outside the functional component too if you don't want to use a ref.

    • Try to do things the functional (immutable) way, and not the "old" Javascript way. For example, when adding to an array, use [...oldArray, newValue] and when assigning to an object, use {...oldObject, newKey: newValue }.

    There are more things to say but I think it's better for you to study the refactored version and you can let me know if you wonder about anything.