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.
Top comments (6)
This seems like an anti-pattern that fights again React architecture. The last example with
useCallback
is a correct way to use React.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.
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....
Thanks for the link. On that page, it says directly after:
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.I love the post as an explanation of how useEffect and useRef work, but I agree with Alex. Seeing
useCallback
is less confusing.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.