React Gotchas
Stale closures, reference equality, useEffect pitfalls, and the mental model mistakes that cause subtle bugs.
React Gotchas
React's hooks and rendering model have specific mental models you need to internalize. These are the most common mistakes that cause subtle, hard-to-debug issues.
1. Stale Closures in Hooks
The trap:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always 0!
setCount(count + 1); // always sets to 1!
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — closure captures initial count
return <div>{count}</div>;
}
What happens: The effect closure captures the count value from the render it was created in. With [] as deps, it never re-runs, so it always sees count = 0.
The fix:
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // functional update — no stale closure
}, 1000);
return () => clearInterval(id);
}, []);
Use functional updates (prev => prev + 1) when the new state depends on the previous state inside a stale closure.
2. Reference Equality and Re-renders
The trap:
function Parent() {
const style = { color: "red" }; // new object every render
return <Child style={style} />;
}
const Child = React.memo(({ style }) => {
// Re-renders every time because {} !== {}
return <div style={style}>Hello</div>;
});
What happens: Every render creates a new object for style. Even though the values are identical, React.memo does a shallow comparison and sees a different reference, so it re-renders.
The fix:
function Parent() {
const style = useMemo(() => ({ color: "red" }), []); // stable reference
return <Child style={style} />;
}
// Or define it outside the component if it's truly static
const style = { color: "red" };
3. useEffect Cleanup and Async Race Conditions
The trap:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data)); // may set state for a stale userId!
}, [userId]);
return <div>{user?.name}</div>;
}
What happens: If userId changes rapidly, the first fetch might resolve after the second. The component shows the wrong user data — a classic race condition.
The fix:
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; }; // cleanup on re-run
}, [userId]);
The cleanup function runs before the effect re-runs for a new userId, preventing stale updates.
4. useMemo / useCallback Misuse
The trap:
function List({ items }) {
// Premature optimization — useMemo has its own overhead
const sorted = useMemo(() => [...items].sort(), [items]);
// useCallback for a handler that's never passed to a memoized child
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return <div onClick={handleClick}>{sorted.map(/* ... */)}</div>;
}
What happens: useMemo and useCallback have a cost — they store the previous value and compare deps on every render. If the computation is cheap or the result isn't passed to a memoized child, you're adding overhead, not saving it.
The fix:
function List({ items }) {
// Just compute it — React renders are fast
const sorted = [...items].sort();
const handleClick = () => console.log("clicked");
return <div onClick={handleClick}>{sorted.map(/* ... */)}</div>;
}
// Only memoize when:
// 1. The value is passed to React.memo children
// 2. The computation is genuinely expensive
// 3. You've profiled and confirmed unnecessary re-renders
5. State Updates Are Asynchronous
The trap:
function Form() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count is still 0 here — all three set it to 1!
};
return <button onClick={handleClick}>{count}</button>;
}
What happens: State updates are batched. Each setCount(count + 1) uses the same count value (0) from the current closure. The result is 1, not 3.
The fix:
const handleClick = () => {
setCount((prev) => prev + 1); // 0 -> 1
setCount((prev) => prev + 1); // 1 -> 2
setCount((prev) => prev + 1); // 2 -> 3
};
Use functional updates when the next state depends on the previous state.
6. Key Prop Mistakes
The trap:
{items.map((item, index) => (
<TodoItem key={index} item={item} />
))}
What happens: Using index as key breaks when items are reordered, inserted, or deleted. React associates state with the key — if you delete the first item, the second item inherits the first item's state (checked/unchecked, input values, etc.).
The fix:
{items.map((item) => (
<TodoItem key={item.id} item={item} />
))}
Use a stable, unique identifier (database ID, UUID). Index is only safe for lists that never change order.
7. useEffect Dependency Array Pitfalls
The trap:
// 1. Empty array — runs once, stale closures
useEffect(() => {
window.addEventListener("resize", () => setWidth(window.innerWidth));
}, []); // missing cleanup!
// 2. Object as dependency — infinite loop
useEffect(() => {
fetchData(filters);
}, [filters]); // if filters is { page: 1 }, new object every render → infinite loop
// 3. Omitted array — runs every render
useEffect(() => {
console.log("I run on EVERY render");
}); // no array at all = runs after every render
What happens: The dependency array controls when the effect re-runs. Getting it wrong causes stale data, infinite loops, or performance problems.
The fix:
// 1. Always clean up event listeners
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
// 2. Memoize object deps or use primitive values
const page = filters.page;
useEffect(() => {
fetchData({ page });
}, [page]);
// 3. Be intentional — every effect needs a dependency array
8. Derived State Anti-Pattern
The trap:
function UserGreeting({ user }) {
const [fullName, setFullName] = useState(
user.firstName + " " + user.lastName
);
// Now you need useEffect to keep it in sync...
useEffect(() => {
setFullName(user.firstName + " " + user.lastName);
}, [user.firstName, user.lastName]);
return <h1>Hello, {fullName}</h1>;
}
What happens: Copying props into state creates a synchronization problem. You need an effect to keep them in sync, which means the component renders with stale data for one frame.
The fix:
function UserGreeting({ user }) {
// Just compute it during render — no state needed
const fullName = user.firstName + " " + user.lastName;
return <h1>Hello, {fullName}</h1>;
}
Rule: If a value can be computed from props or other state, don't put it in state.
9. Conditional Hooks
The trap:
function Profile({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // ERROR!
}
const [theme, setTheme] = useState("dark");
// ...
}
What happens: Hooks must be called in the exact same order on every render. React identifies hooks by their call order, not by name. Conditional hooks change the order when conditions change, corrupting state.
The fix:
function Profile({ isLoggedIn }) {
const [user, setUser] = useState(null); // always call
const [theme, setTheme] = useState("dark");
// Use the condition in the render or effect, not around the hook
if (!isLoggedIn) return <LoginPrompt />;
return <div>{user?.name}</div>;
}
10. React.memo Shallow Comparison
The trap:
const ExpensiveList = React.memo(({ items, onItemClick }) => {
return items.map((item) => (
<div key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</div>
));
});
function Parent() {
const [items] = useState(getItems());
// New function reference every render — React.memo is useless!
const handleClick = (id) => console.log(id);
return <ExpensiveList items={items} onItemClick={handleClick} />;
}
What happens: React.memo does a shallow comparison of all props. If any prop is a new reference (like an inline function), the memoized component re-renders anyway. The memo is doing nothing.
The fix:
function Parent() {
const [items] = useState(getItems());
// Stable reference with useCallback
const handleClick = useCallback((id) => {
console.log(id);
}, []);
return <ExpensiveList items={items} onItemClick={handleClick} />;
}
For React.memo to work, all props must have stable references. If you can't stabilize all props, React.memo is adding overhead for no benefit.