DEV Community

MapleLeaf
MapleLeaf

Posted on

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.

Top comments (6)

Collapse
 
wialy profile image
Alex Ilchenko

This seems like an anti-pattern that fights again React architecture. The last example with useCallback is a correct way to use React.

Collapse
 
mapleleaf profile image
MapleLeaf

Sure, but like i said in the post, it's unergonomic, and makes the component more cumbersome to use. I feel that maximum correctness isn't always the best approach, all things considered. Lots of third party libraries use a similar approach to this.

Collapse
 
wialy profile image
Alex Ilchenko

Sure, that might be convenient for someone not to worry about arguments being passed to the component. However, IMO optimization should start from top to bottom, and the person who writes the code should be aware of the callback being created in every render. And it's better to eliminate the reason for the problem, not the symptom.
The React docs have a warning about passing an arrow function as a prop:
pl.reactjs.org/docs/faq-functions....

Thread Thread
 
mapleleaf profile image
MapleLeaf • Edited

Thanks for the link. On that page, it says directly after:

Is it OK to use arrow functions in render methods?

Generally speaking, yes, it is OK, and it is often the easiest way to pass parameters to callback functions.

If you do have performance issues, by all means, optimize!

I'll also note that you can see this technique in practice on Dan Abramov's blog. In fact, I learned this technique from that post, and found it useful in a lot more cases after generalizing it. You can find another variation of it directly on the react docs. Granted, there's a warning there also recommending to use useCallback, but I made sure to mirror that same warning.

That being said, I might revise the post to list useCallback as the #1 preferred solution in light of all this. However, especially in the case of third party hooks and libraries, user ergonomics is really important, and this technique is a really nice one to know about when wanting to make a user-friendly interface.

Collapse
 
sunnysingh profile image
Sunny Singh

I love the post as an explanation of how useEffect and useRef work, but I agree with Alex. Seeing useCallback is less confusing.

Collapse
 
wialy profile image
Alex Ilchenko

Yes, the explanation is very nice and a lot of aspects are covered. Also, the warning about not to overuse this technique is cool. However I'm afraid it may encourage people to do so, and IMO there's no reason to do that at all.