Trouble with useEffect running every render? `useEffectRef` to the rescue!

mapleleaf

MapleLeaf

Posted on January 21, 2021

Trouble with useEffect running every render? `useEffectRef` to the rescue!

The problem

Here's the standard contrived Counter component, except I've added an onChange prop, so that the parent component can listen to when the count is updated.

function Counter({ onChange }) {
    const [count, setCount] = useState(0)

    useEffect(() => {
        onChange(count)
    }, [count, onChange])

    return (
        <>
            <p>{count}</p>
            <button onClick={() => setCount((c) => c + 1)}>+</button>
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

If you use the react-hooks eslint rule, which is built into Create React App, you'll see that it tells you to add onChange and count to the dependency array.

Usually, the eslint rule is right, and abiding by it will help prevent bugs. But in practice, this can cause the effect to run on every render.

// every render, this callback function is a new, fresh value
// if a state update happens here, or higher up,
// the effect in `Counter` will run,
// and this alert gets called
// ...every update
<Counter onChange={(newCount) => alert(`new count: ${newCount}`)} />
Enter fullscreen mode Exit fullscreen mode

No good! We only want to listen to changes, not all updates! 🙃

The solution

Before I continue, consider this a last resort. If you use this for lots of values, then your effects will miss some important updates, and you'll end up with a stale UI. Reserve this for things that change every render, which are usually callback props. For objects, this might work a lot better.

Anyway, here's my preferred solution, which I feel aligns well with the intended mindset of hooks.

import { useState, useEffect, useRef } from "react"

function Counter({ onChange }) {
    const [count, setCount] = useState(0)

    const onChangeRef = useRef(onChange)
    useEffect(() => {
        onChangeRef.current = onChange
    })

    useEffect(() => {
        onChangeRef.current(count)
    }, [count, onChangeRef])

    return (
        <>
            <p>{count}</p>
            <button onClick={() => setCount((c) => c + 1)}>+</button>
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

This works because refs have free floating, mutable values. They can be changed without causing re-renders, and aren't a part of the reactive flow, like state and props are.

Effects run from top to bottom in the component. The first effect runs and updates onChangeRef.current to whatever callback we've been passed down. Then the second effect runs, and calls it.

You can package the above in a custom hook for reuse. It comes in handy, especially for callback props.

import { useState, useEffect, useRef } from "react"

function Counter({ onChange }) {
    const [count, setCount] = useState(0)

    const onChangeRef = useEffectRef(onChange)
    useEffect(() => {
        onChangeRef.current(count)
    }, [count, onChangeRef])

    return (
        <>
            <p>{count}</p>
            <button onClick={() => setCount((c) => c + 1)}>+</button>
        </>
    )
}

function useEffectRef(value) {
    const ref = useRef(value)
    useEffect(() => {
        ref.current = value
    })
    return ref
}
Enter fullscreen mode Exit fullscreen mode

Note: the ESLint rule will tell you to add onChangeRef to the effect dependencies. Any component-scoped value used in an effect should be a dependency. Adding it isn't a problem in practice; it doesn't change, so it won't trigger re-renders.

Alternatives

Call the callback prop while updating the value

function Counter({ onChange }) {
    const [count, setCount] = useState(0)

    const handleClick = () => {
        setCount((c) => c + 1)
        onChange(c + 1)
    }

    return (
        <>
            <p>{count}</p>
            <button onClick={handleClick}>+</button>
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

This works well in this contrived example, and this may even be better for your case!

However, let's say we add a minus button to this component. Then we have to remember to call the callback when that's clicked as well, and for any other potential case it updates. That, and notice we have to put the update logic twice (c + 1), due to the use of the callback prop. This is somewhat error-prone.

I find an effect is more future proof, and more clearly conveys the intent of "call onChange whenever count changes".

However, this path does let you avoid mucking around with refs, so it still makes for a good alternative. Just giving one more potential tool in the toolbox 🛠

useCallback on the parent

const handleChange = useCallback((count) => {
  alert(count)
}, [])

<Counter onChange={handleChange} />
Enter fullscreen mode Exit fullscreen mode

This works, and is probably the "most correct" solution, but having to useCallback every time you want to pass a callback prop is unergonomic, and easy to forget.

// eslint-disable-line

This could cause future bugs if you need to add a new dependency and forget to. The rule is rarely wrong in practice, only ever if you're doing something weird, like a custom dependency array.

💖 💪 🙅 🚩
mapleleaf
MapleLeaf

Posted on January 21, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related