⚛️ Claude Code Skill Available: These antipatterns are prevented by the
ideal-react-componentskill in SkillBox. Install withnpx skills add antjanus/skillboxand invoke with/ideal-react-componentto automatically apply hooks best practices. Read the announcement.
Table of Contents
The useEffect “onChange” callback
Something I haven’t seen before until recently has been this “onChange callback” pattern with useEffect.
The idea is that the useEffect will run any time a local state is updated and let its parent know of any changes. Like so:
const FormInput = ({ formValue, onChange }) => {
const [val, setVal] = useState(formValue);
useEffect(() => {
if (val !== formValue) {
onChange(formValue);
}
}, [val]);
}; At first glance, this seems like a neat pattern. It doesn’t matter where val gets updated whether that’s in a child component, a conditional component, or in an input field. Once it’s updated, the parent will be notified.
Unfortunately, you’ll be wasting renders. Here’s why:
- once you update state, the component will re-render
- mid-render,
useEffectwill queue up to run - use effect runs after render
- parent component finds out about change and starts to update itself – causing re-renders
- component re-renders one more time with the same exact data as before
useEffectran
The useEffect does its job but causes useless re-renders. Worse yet, you’ll immediately spot the code smell if you use the react hooks lint rules which will force you to add all the dependencies you use:
useEffect(() => {
if (val !== formValue) {
onChange(formValue);
}
}, [val, formValue, onChange]); No longer looks right, does it? In fact, if the parent’s onChange does anything to modify the formValue, then you’ll be stuck in an endless loop. If onChange gets recreated every render (which is common since it’s a callback), it’ll queue up this useEffect each render.
What to do instead:
Option 1: wrap your setVal
const FormInput = ({ formValue, onChange }) => {
const [val, setVal] = useState(formValue);
const updateVal = (value) => {
setVal(value);
onChange(value);
};
}; Option 2: run onChange after setVal inline
const FormInput = ({ formValue, onChange }) => {
const [val, setVal] = useState(formValue);
return (
<input
type="text"
onChange={(e) => {
const value = e.target.value;
setVal(value);
onChange(value);
}}
/>
);
}; useState initial state
I’ve seen this happen quite a few times – you might expect for the useState hook to update itself with new data if its state is derived from props. Here’s an example
const FormInput = ({ formValue }) => {
const [val, setVal] = useState(formValue);
}; When formValue updates, it does not update the state. This is an important but fairly common gotcha when you first start working with hooks. Whatever value you pass in there on the first render is what the state’s initial value will be. And then, no matter what gets passed into useState, it won’t update the value.
Essentially: once you initialize state, the only way you can update it is via setVal or whatever you call your setter.
Here’s another example with a function state initializer
const FormInput = ({ formValue }) => {
const [val, setVal] = useState(() => {
const value = format(formValue);
return value;
});
}; The initializer will only run once and retrieve the resulting value for its initial state. After that, you’re once again left only to update the state via setVal.
Note: if you do something like useState(expensiveComputation(formValue)), you’ll still run expensiveComputation on each render, and the result will be thrown right into the garbage. (credit for this gotcha goes to one of my colleagues)
non-exhaustive useEffect dependencies
Might be a hot take! You ever use that exhaustive-deps eslint plugin and get annoyed at just how much stuff it pulls in into dependencies? When so many of the dependencies don’t need to trigger an effect?
The exhaustive dependencies actually avoid quite a few problems and reveal quite a few issues. Take this example:
const openModal = () => {
setModalOpen(true)
onChange(someDep)
someOtherExternalCallback(someDep)
}
useEffect(() => {
if (someDep === true) {
openModal()
}
}, [someDep]) In the example, the eslint extension will try to add openModal and you might be wondering – “why?”
Immediately, what pops into my head is:
openModaldependency will re-trigger the useEffect on each render because it gets recreatedopenModalshould be memoized usinguseCallback- if
openModalis used in any otheruseEffectup and down the tree, it’ll wreak havoc - if anything in the
useEffectis asynchronous, the incorrect version ofopenModalwill run (one with a staleonChangeandsomeOtherExternalCallback
The cool thing is if the useEffect doesn’t work correctly with all dependencies in there – it probably means you’re doing something wrong! :D
Here’s an async example:
const [data, setData] = useState('data')
const openModal = () => {
setModalOpen(true)
onChange({ someDep, data })
someOtherExternalCallback(someDep)
}
useEffect(() => {
if (someDep) {
fetch('/data/' + someDep)
.then(() => {
openModal()
})
}
}, [someDep]) In this case, if anything about openModal’s implicit dependencies changed, we’d be calling the incorrect callbacks. And guess what? If you’re writing unmemoized code up and down the component tree, you’re more than likely to call stale callback with stale data and create a jumble out of your code.
Note: I’m pretty sure these types of complexities are why some developers still stay away from hooks
