React hooks are approachable enough to learn in an afternoon. They are subtle enough that most engineers are misusing at least two of them in production today.
The problems are not syntax errors - they compile fine. The problems show up as slow renders, extra API calls, stale data, and memory leaks that are annoying to trace.
The Dependency Array Is Not Optional
The most misused hook in React is useEffect with an incorrect dependency array.
The false empty array:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // Wrong - userId is used but not listed
return <div>{user?.name}</div>;
}
This runs only once, on mount. If userId changes because the user navigates to a different profile, the effect does not re-run. You show the old user’s data under a new URL. This is a stale closure bug.
The correct version:
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Correct - re-runs when userId changes
The missing cleanup:
useEffect(() => {
const subscription = eventBus.subscribe('update', handleUpdate);
// Missing cleanup - subscription leaks when component unmounts
}, []);
// Correct:
useEffect(() => {
const subscription = eventBus.subscribe('update', handleUpdate);
return () => subscription.unsubscribe(); // Cleanup function
}, []);
Every useEffect that creates a subscription, timer, event listener, or WebSocket connection needs a cleanup function.
Infinite Render Loops
This pattern appears in code reviews regularly:
function DataTable() {
const [filters, setFilters] = useState({ page: 1, limit: 20 });
useEffect(() => {
fetchData(filters).then(setData);
}, [filters]); // Runs when filters changes
// Bug: this creates a NEW object on every render
const handlePageChange = (page) => {
setFilters({ ...filters, page }); // New object reference
};
}
This does not cause an infinite loop by itself, but the related pattern does:
useEffect(() => {
const config = { baseUrl: '/api' }; // New object on every render
fetchData(config);
setData(result); // Triggers re-render
}, [config]); // config is always "new" - runs every render
The dependency array uses referential equality for objects. An object literal {} is always a new reference. Use useMemo for object dependencies, or move the object outside the component.
useCallback and useMemo: When They Help and When They Do Not
Both hooks exist to prevent unnecessary re-computation, but they have overhead. Using them everywhere makes things slower.
useMemo is worthwhile for:
// Expensive computation that does not need to run on every render
const sortedItems = useMemo(
() => items.sort((a, b) => b.price - a.price),
[items]
);
// Stable object reference needed for useEffect dependency
const queryParams = useMemo(
() => ({ page, limit, search }),
[page, limit, search]
);
useMemo is not worthwhile for:
// This is a simple lookup - memoization overhead exceeds savings
const displayName = useMemo(
() => user.firstName + ' ' + user.lastName,
[user.firstName, user.lastName]
);
// Just do: const displayName = `${user.firstName} ${user.lastName}`;
useCallback is worthwhile for:
// When passed to a child component wrapped in React.memo
const handleSubmit = useCallback(
(data) => {
mutate(data);
},
[mutate]
);
return <Form onSubmit={handleSubmit} />;
useCallback is not worthwhile for:
// When the child is not memoized, useCallback does nothing useful
const handleClick = useCallback(() => {
doSomething();
}, []);
return <button onClick={handleClick}>Click</button>;
// The button component is not memoized, so it re-renders regardless
State Updates in Loops
// Bad: triggers re-render for each update
function processItems(items) {
items.forEach(item => {
setProcessed(prev => [...prev, item.id]); // Re-render for each item
});
}
// Good: batch the update
function processItems(items) {
setProcessed(prev => [...prev, ...items.map(i => i.id)]);
}
React 18 introduced automatic batching, which helps here, but batching does not apply across async boundaries. Still prefer single state updates where possible.
The Derived State Trap
// Anti-pattern: syncing state that can be derived
function UserCard({ user }) {
const [displayName, setDisplayName] = useState(
`${user.firstName} ${user.lastName}`
);
// Now you need an effect to sync it when user changes
useEffect(() => {
setDisplayName(`${user.firstName} ${user.lastName}`);
}, [user.firstName, user.lastName]);
}
// Correct: derive during render, no state needed
function UserCard({ user }) {
const displayName = `${user.firstName} ${user.lastName}`;
// Computed on every render, always fresh, no effect needed
}
If a value can be computed from existing state or props, do not put it in state. State that mirrors props is one of the most common sources of stale data bugs.
useReducer for Complex State
When multiple pieces of state update together, useReducer prevents the intermediate render states that useState creates:
// Multiple useState calls - intermediate renders between updates
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const fetchData = async () => {
setLoading(true);
setError(null); // render 1
try {
const result = await getData();
setData(result); // render 2
setLoading(false); // render 3
} catch (e) {
setError(e);
setLoading(false);
}
};
// useReducer - single dispatch, single render
const [state, dispatch] = useReducer(dataReducer, {
loading: false, data: null, error: null
});
const fetchData = async () => {
dispatch({ type: 'FETCH_START' }); // Single render
try {
const result = await getData();
dispatch({ type: 'FETCH_SUCCESS', payload: result }); // Single render
} catch (e) {
dispatch({ type: 'FETCH_ERROR', payload: e }); // Single render
}
};
Bottom Line
React hooks have well-documented footguns that most engineers encounter through bugs rather than upfront reading. Incorrect dependency arrays cause stale data bugs. Missing cleanup causes memory leaks. Unnecessary memoization adds overhead without benefit. Derived state in useState causes synchronization bugs.
The fixes are all straightforward once you know the patterns. Run the React DevTools Profiler on your most interactive components and look for render counts that exceed what you expect. The hooks in this post are responsible for most of what you find.
Comments