Trouble with useEffect running every render? `useEffectRef` to the rescue!
MapleLeaf
Posted on January 21, 2021
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>
</>
)
}
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}`)} />
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>
</>
)
}
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
}
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>
</>
)
}
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} />
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.
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
January 21, 2021