React Hooks - Common Pitfalls
Hooks are a powerful new paradigm in React that open up a world of possibilities. I believe they are one of the best things to happen to React in quite a while. Encapsulating and sharing business logic and state in a clean, reusable way? Sign me up.
Unfortunately, as with anything new and powerful, it is also easy for beginners (and even experienced devs) to misuse hooks in ways that make code more difficult to understand, debug, or performant. I'm going to do my best to cover a few of the common pitfalls I have personally experienced or seen.
useEffect - The Double-Edged Sword
useEffect
is arguably the most versatile hook, but also the easiest to get wrong. It lets you perform side effects in function components, replacing lifecycle methods like componentDidMount
, componentDidUpdate
, and componentWillUnmount
.
Pitfall 1: The Infamous Dependency Array
The second argument to useEffect
is the dependency array. It tells React when to re-run your effect.
- No array: Runs after every render. Often leads to infinite loops if the effect updates state.
- Empty array (
[]
): Runs only once after the initial render (likecomponentDidMount
). Great for setup, but won't react to prop/state changes. - Array with dependencies (
[propA, stateB]
): Runs after the initial render and whenever any value in the array changes.
The common mistake is missing dependencies. If your effect uses a prop or state variable, but it's not listed in the array, the effect will run with a stale value from a previous render when it re-runs due to other dependencies changing. This leads to subtle and frustrating bugs.
function Counter({ step }) {
const [count, setCount] = useState(0);
useEffect(() => {
// This effect uses 'step', but doesn't list it as a dependency
const intervalId = setInterval(() => {
console.log(`Incrementing by ${step}`); // Logs the initial 'step' value forever!
setCount(c => c + step); // Uses the initial 'step' value forever!
}, 1000);
return () => clearInterval(intervalId);
}, []); // <--- Missing 'step' dependency!
return <div>Count: {count}</div>;
}
Solution: Always include all props and state values read inside the effect in the dependency array. Thankfully, the eslint-plugin-react-hooks
package has an exhaustive-deps
rule that warns you about this. Use it.
Another common issue is including unstable dependencies (like functions or objects defined inside the component body without useCallback
or useMemo
) which causes the effect to run more often than necessary.
Pitfall 2: Using useEffect
for Derived State
Sometimes, you might be tempted to use useEffect
to update state based on prop changes.
function UserProfile({ user }) {
const [displayName, setDisplayName] = useState('');
// DON'T DO THIS
useEffect(() => {
setDisplayName(user.firstName + ' ' + user.lastName);
}, [user.firstName, user.lastName]); // Runs whenever name changes
return <div>Display Name: {displayName}</div>;
}
This is usually unnecessary. State should represent the minimal source of truth. Derived data can often be calculated directly during rendering.
function UserProfile({ user }) {
// Calculate directly during render
const displayName = user.firstName + ' ' + user.lastName;
return <div>Display Name: {displayName}</div>;
}
This is simpler, more performant, and avoids potential intermediate render states. Use useMemo
if the calculation itself is expensive.
Pitfall 3: Forgetting Cleanup
If your effect sets up a subscription, timer, or event listener, you must return a cleanup function to remove it when the component unmounts or before the effect re-runs. Otherwise, you'll have memory leaks or unexpected behavior.
useEffect(() => {
const subscription = someService.subscribe(data => { ... });
// Cleanup function
return () => {
subscription.unsubscribe();
};
}, [someService]); // Assuming someService is stable
useState and Stale Closures
useState
is fundamental, but it can trip you up with asynchronous operations or event handlers defined within closures.
Pitfall: Reading Stale State
Consider an event handler that reads state and then performs an async action:
function DelayedLogger() {
const [count, setCount] = useState(0);
const logCountLater = () => {
setTimeout(() => {
// This 'count' is the value from when logCountLater was *defined*,
// not necessarily the *current* count value!
console.log(`Count was: ${count}`);
}, 3000);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={logCountLater}>Log Count After 3s</button>
</div>
);
}
If you click Increment several times quickly and then click Log, the logged value might be lower than the displayed count because the logCountLater
function closed over the count
value from the render it was created in.
Solution 1: Functional Updates: Use the functional update form of the state setter if you need the previous state.
// Inside useEffect or callback where you need previous state
setCount(prevCount => prevCount + 1);
Solution 2: useRef
: If you need the latest value inside a callback that shouldn't re-run often (like a useEffect
with []
), you can store the value in a ref.
function DelayedLogger() {
const [count, setCount] = useState(0);
const latestCountRef = useRef(count);
// Keep the ref updated
useEffect(() => {
latestCountRef.current = count;
}, [count]);
const logCountLater = () => {
setTimeout(() => {
console.log(`Latest count is: ${latestCountRef.current}`); // Reads the latest value
}, 3000);
};
// ... rest of component
}
Custom Hooks
Custom hooks are fantastic for extracting reusable logic, but remember the rules:
- Name must start with
use
: This allows React and ESLint to enforce the Rules of Hooks. - Follow the Rules of Hooks: Only call hooks at the top level of your function component or other custom hooks. Don't call them inside loops, conditions, or nested functions.
Conclusion
Hooks offer a more direct and functional way to manage state and side effects in React. However, understanding their nuances, particularly around dependency arrays and closures, is crucial to avoid common pitfalls. Embrace the ESLint plugin, think carefully about dependencies, calculate derived state directly, and always clean up your effects. Happy hooking!