// PROBLEM: handler captures old state value// FIX: use updater function or ref
example
function BrokenCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// BUG: count is always 0 (captured from initial render)
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // empty deps = closure captures initial countreturn <p>{count}</p>; // stuck at 1
}
function FixedCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// FIX: updater function always has the latest value
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>; // increments correctly
}
Note Stale closures happen when a callback captures a state value that never updates. Common in setInterval, setTimeout, and event listeners set up in useEffect. Use the updater form of setState or store values in a ref.
stale closureold state valueclosure bugsetInterval stalestate not updating
Direct State Mutation
syntax
// BAD: mutating state directly
state.items.push(newItem);
setState(state);
// GOOD: create a new reference
setState({ ...state, items: [...state.items, newItem] });
example
function BrokenTags() {
const [tags, setTags] = useState(['react', 'hooks']);
function addTag(tag) {
// BUG: push mutates the existing array
tags.push(tag);
setTags(tags); // same reference, React sees no change
}
return (
<div>
{tags.map(t => <span key={t}>{t}</span>)}
<button onClick={() => addTag('new')}>Add</button>
</div>
);
}
function FixedTags() {
const [tags, setTags] = useState(['react', 'hooks']);
function addTag(tag) {
// CORRECT: spread creates a new array
setTags(prev => [...prev, tag]);
}
return (
<div>
{tags.map(t => <span key={t}>{t}</span>)}
<button onClick={() => addTag('new')}>Add</button>
</div>
);
}
Note React uses reference equality (Object.is) to decide whether to re-render. Mutating an object or array in place keeps the same reference, so React skips the update. Always produce a new object/array via spread, map, filter, or slice.
state mutationmutate state directlypush state arraystate not updatingimmutable update
Missing or Bad Keys
syntax
// BAD: index as key with reorderable list
{items.map((item, i) => <Item key={i} />)}
// GOOD: stable unique identifier
{items.map(item => <Item key={item.id} />)}
example
// BUG: using index as key with a removable listfunction BrokenList() {
const [items, setItems] = useState([
{ id: 'a', text: 'First' },
{ id: 'b', text: 'Second' },
{ id: 'c', text: 'Third' },
]);
function removeFirst() {
setItems(prev => prev.slice(1));
}
return (
<div>
{/* Using index: after removing first item,
React thinks items shifted, inputs lose state */}
{items.map((item, index) => (
<div key={index}>
<input defaultValue={item.text} />
</div>
))}
<button onClick={removeFirst}>Remove First</button>
</div>
);
}
// FIX: use item.id as key// {items.map(item => <div key={item.id}>...)}
Note Index keys cause broken behavior when items are added, removed, or reordered. React associates state (including uncontrolled input values) with the key. When keys shift, state gets attached to the wrong item.
key propmissing keyindex as keylist key bugwrong keykey warning
useEffect Dependency Issues
syntax
// MISSING DEPS: effect uses stale values
useEffect(() => { fn(a, b); }, []); // a, b missing// OBJECT DEPS: infinite loop
useEffect(() => { ... }, [{ x: 1 }]); // new ref each render
example
// BUG: missing dependencyfunction Greeting({ name }) {
const [message, setMessage] = useState('');
useEffect(() => {
// name is used but not in deps -- stale value
setMessage(`Hello, ${name}!`);
}, []); // should be [name]return <p>{message}</p>;
}
// BUG: object dependency causes infinite loopfunction DataLoader({ filters }) {
useEffect(() => {
fetchData(filters);
}, [filters]); // if parent recreates filters object each render, infinite loop!// FIX: destructure primitive valuesconst { category, minPrice } = filters;
useEffect(() => {
fetchData({ category, minPrice });
}, [category, minPrice]);
}
Note Enable the eslint-plugin-react-hooks exhaustive-deps rule to catch missing dependencies. For object dependencies, extract primitive values or memoize the object in the parent. Never suppress the lint rule without understanding why.
// CAUSE 1: setState during renderfunction Bad() {
const [x, setX] = useState(0);
setX(1); // triggers re-render during render!
}
// CAUSE 2: object/array in useEffect deps
useEffect(() => { ... }, [{ a: 1 }]); // new ref every render
example
// BUG: calling setState unconditionally during renderfunction BrokenComponent({ data }) {
const [processed, setProcessed] = useState(null);
// This runs on every render, which triggers a new render...
setProcessed(transform(data));
return <Display data={processed} />;
}
// FIX: derive the value without statefunction FixedComponent({ data }) {
const processed = transform(data); // compute inlinereturn <Display data={processed} />;
}
// BUG: onClick={handler()} calls immediatelyfunction BrokenButton() {
const [count, setCount] = useState(0);
// handler() executes during render, calling setCount, loopingreturn <button onClick={setCount(count + 1)}>Click</button>;
}
// FIX: pass function referencefunction FixedButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Click</button>;
}
Note Infinite re-render loops crash your app. The most common causes: (1) calling setState directly in the render body, (2) onClick={fn()} instead of onClick={fn}, (3) useEffect with an object/array literal in the dependency array that creates a new reference every render.
infinite re-rendertoo many rendersrender loopmaximum update depthsetState during render
Overusing useEffect
syntax
// ANTI-PATTERN: effect to sync derived state
useEffect(() => { setB(computeFrom(a)); }, [a]);
// BETTER: compute inlineconst b = computeFrom(a);
Note Each unnecessary useEffect + setState causes an extra render cycle. If a value can be derived from props or state, compute it during render. Reserve useEffect for genuine side effects: API calls, subscriptions, DOM manipulation, and third-party library integration.
overuse useEffectuseEffect anti-patterneffect chainderived state effectunnecessary effect
Setting State After Unmount
syntax
// PROBLEM: async callback sets state on unmounted component
useEffect(() => {
fetchData().then(data => setState(data)); // may be unmounted!
}, []);
// FIX: track mounted status
useEffect(() => {
let active = true;
fetchData().then(data => { if (active) setState(data); });
return () => { active = false; };
}, []);
example
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
asyncfunction loadUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Only update state if this effect is still activeif (!cancelled) {
setUser(data);
}
}
loadUser();
return () => {
cancelled = true;
};
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
Note While React 18+ no longer warns about this, it is still a logic bug. Without the cancelled flag, a slow request for user A could resolve after switching to user B, overwriting user B's data with user A's data. Always use a cleanup flag or AbortController.
unmount state updatecancelled requestrace conditionabort fetchcleanup async
State Batching Behavior
syntax
// React 18+ batches ALL state updates automaticallyfunction handleClick() {
setA(1); // does not trigger render
setB(2); // does not trigger render
setC(3); // single render with all three updates
}
example
function StatusPanel() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
asyncfunction loadData() {
setLoading(true);
setError(null);
// React batches these two -- one rendertry {
const result = await fetchDashboardData();
setData(result);
setLoading(false);
// React batches these two -- one render
} catch (err) {
setError(err.message);
setLoading(false);
// React batches these two -- one render
}
}
return (
<div>
{loading && <p>Loading...</p>}
{error && <p>Error: {error}</p>}
{data && <Dashboard data={data} />}
</div>
);
}
Note Since React 18, automatic batching applies to all updates, including those in promises, setTimeout, and native event handlers. You rarely need to worry about batching anymore. If you ever need to force a synchronous render, use flushSync (imported from react-dom) as a last resort.
state batchingmultiple setStateautomatic batchingflushSyncrender batching