React useState Anti-Patterns Every Reviewer Should Flag
From duplicated state to broken hooks — how to spot and fix the most common useState mistakes in React pull requests.
You’re reviewing a React pull request and see useState everywhere. Some states are computed from props, others are syncing with each other, and there’s a useEffect trying to keep everything in sync. Your spider-sense tingles—something’s wrong, but what?
React’s useState is deceptively simple. It’s so easy to use that developers often reach for it when they shouldn’t. This leads to bugs, unnecessary re-renders, and maintenance nightmares.
In this article, we’ll explore seven common useState anti-patterns that every code reviewer should flag, with examples of what’s wrong and how to fix it.
Anti-Pattern 1: Duplicated/Derived State
The Bad
function ProductCard({ product }) {
const [price, setPrice] = useState(product.price);
const [tax, setTax] = useState(product.price * 0.1);
const [total, setTotal] = useState(product.price * 1.1);
// Trying to keep derived state in sync
useEffect(() => {
setTax(price * 0.1);
setTotal(price * 1.1);
}, [price]);
return (
<div>
<p>Price: ${price}</p>
<p>Tax: ${tax}</p>
<p>Total: ${total}</p>
</div>
);
}
Why It’s Bad
- State is duplicated unnecessarily
useEffectcreates a second render cycle- If
product.priceprop changes, state is stale - Three state variables when you only need one (or zero!)
The Good
function ProductCard({ product }) {
// Just compute derived values directly
const tax = product.price * 0.1;
const total = product.price * 1.1;
return (
<div>
<p>Price: ${product.price}</p>
<p>Tax: ${tax.toFixed(2)}</p>
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}
If computation is expensive, use useMemo:
function ProductList({ products }) {
// Only recalculate when products array changes
const totalValue = useMemo(() => {
return products.reduce((sum, p) => sum + p.price * p.quantity, 0);
}, [products]);
return <div>Total: ${totalValue}</div>;
}
Rule of Thumb
Don’t store anything in state that can be computed from existing state or props.
Anti-Pattern 2: Mutating State Directly
The Bad
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false }
]);
function toggleTodo(id) {
// Bad: Mutating state directly
const todo = todos.find(t => t.id === id);
todo.done = !todo.done;
setTodos(todos); // React won't detect the change!
}
function addTodo(text) {
// Bad: Mutating array directly
todos.push({ id: Date.now(), text, done: false });
setTodos(todos); // React won't re-render!
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.done ? '✅' : '⬜'} {todo.text}
</li>
))}
</ul>
);
}
Why It’s Bad
React compares state by reference. If you mutate the object/array and pass the same reference, React thinks nothing changed and won’t re-render.
The Good
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false }
]);
function toggleTodo(id) {
// Good: Create new array with updated object
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, done: !todo.done }
: todo
));
}
function addTodo(text) {
// Good: Create new array with new item
setTodos([...todos, { id: Date.now(), text, done: false }]);
}
function deleteTodo(id) {
// Good: Create new array without item
setTodos(todos.filter(todo => todo.id !== id));
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span onClick={() => toggleTodo(todo.id)}>
{todo.done ? '✅' : '⬜'} {todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
<button onClick={() => addTodo('New task')}>Add Todo</button>
</ul>
);
}
For Nested Objects
function UserProfile() {
const [user, setUser] = useState({
name: 'John',
address: {
city: 'New York',
zip: '10001'
}
});
function updateCity(newCity) {
// Bad: Mutates nested object
// user.address.city = newCity;
// setUser(user);
// Good: Create new objects all the way down
setUser({
...user,
address: {
...user.address,
city: newCity
}
});
}
return <div>{user.address.city}</div>;
}
For deeply nested state, consider using Immer or useReducer.
Anti-Pattern 3: State That Should Be a Ref
The Bad
function VideoPlayer({ src }) {
const [videoRef, setVideoRef] = useState(null);
const [playCount, setPlayCount] = useState(0);
function handlePlay() {
// Bad: This triggers a re-render!
setPlayCount(playCount + 1);
console.log('Played', playCount, 'times');
}
return (
<video
ref={setVideoRef}
src={src}
onPlay={handlePlay}
/>
);
}
Why It’s Bad
playCount is incremented just for tracking, but changing it triggers a re-render. If the value isn’t displayed in the UI, it shouldn’t be in state.
The Good
function VideoPlayer({ src }) {
const videoRef = useRef(null);
const playCountRef = useRef(0);
function handlePlay() {
// Good: Updates without re-rendering
playCountRef.current += 1;
console.log('Played', playCountRef.current, 'times');
}
return (
<video
ref={videoRef}
src={src}
onPlay={handlePlay}
/>
);
}
When to Use Refs vs State
Use useState when:
- The value is displayed in the UI
- Changes should trigger re-renders
Use useRef when:
- Storing DOM references
- Tracking values that don’t affect rendering (timers, previous values, counters)
- Storing mutable values that persist across renders
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
function start() {
// Store interval ID in ref (doesn't need to trigger render)
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1); // This DOES trigger render
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
return (
<div>
<p>Time: {seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Anti-Pattern 4: Multiple Related States Instead of Reducer
The Bad
function UserRegistration() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setIsLoading(true);
setError(null);
setSuccess(false);
try {
await registerUser({ username, email, password });
setSuccess(true);
setUsername('');
setEmail('');
setPassword('');
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
}
// ... render form
}
Why It’s Bad
- Six related state variables that change together
- Easy to forget to update one
- Hard to reason about state transitions
- Multiple setState calls cause multiple re-renders
The Good
const initialState = {
username: '',
email: '',
password: '',
isLoading: false,
error: null,
success: false
};
function registrationReducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SUBMIT_START':
return { ...state, isLoading: true, error: null, success: false };
case 'SUBMIT_SUCCESS':
return {
...initialState, // Reset form
success: true
};
case 'SUBMIT_ERROR':
return { ...state, isLoading: false, error: action.error };
default:
return state;
}
}
function UserRegistration() {
const [state, dispatch] = useReducer(registrationReducer, initialState);
async function handleSubmit(e) {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await registerUser({
username: state.username,
email: state.email,
password: state.password
});
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', error: err.message });
}
}
function handleFieldChange(field, value) {
dispatch({ type: 'SET_FIELD', field, value });
}
return (
<form onSubmit={handleSubmit}>
<input
value={state.username}
onChange={e => handleFieldChange('username', e.target.value)}
/>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Registration successful!</p>}
<button disabled={state.isLoading}>
{state.isLoading ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Benefits of useReducer
- All related state in one object
- State transitions are explicit and testable
- Reducer is pure function (easy to test)
- Single dispatch for complex state updates
Anti-Pattern 5: Stale Closures
The Bad
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// Bad: Creates stale closure
setTimeout(() => {
setCount(count + 1); // Uses count value from when timeout was created
}, 3000);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment after 3s</button>
</div>
);
}
Test it:
- Click button 3 times quickly
- Wait 3 seconds
- Count is 1, not 3!
Why It’s Bad
Each setTimeout captures the count value when the function was created. All three closures have count = 0, so they all set it to 1.
The Good: Functional Updates
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// Good: Use functional update
setTimeout(() => {
setCount(prevCount => prevCount + 1); // Uses latest value
}, 3000);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment after 3s</button>
</div>
);
}
Now clicking 3 times → count becomes 3 ✅
Another Example: Event Handlers in Effects
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function handleMessage(msg) {
// Bad: Stale closure over message
console.log('Current message:', message);
setMessage(msg);
}
socket.on('message', handleMessage);
return () => socket.off('message', handleMessage);
}, [roomId]); // message not in dependencies!
return <input value={message} onChange={e => setMessage(e.target.value)} />;
}
handleMessage always logs the initial empty string because it’s in a stale closure.
Fix with ref:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const messageRef = useRef(message);
useEffect(() => {
messageRef.current = message; // Keep ref up to date
});
useEffect(() => {
function handleMessage(msg) {
console.log('Current message:', messageRef.current); // Uses latest
setMessage(msg);
}
socket.on('message', handleMessage);
return () => socket.off('message', handleMessage);
}, [roomId]);
return <input value={message} onChange={e => setMessage(e.target.value)} />;
}
Anti-Pattern 6: Expensive Initialization Without Lazy Initializer
The Bad
function ExpensiveComponent() {
// Bad: Runs on every render!
const [data, setData] = useState(computeExpensiveValue());
return <div>{data}</div>;
}
function computeExpensiveValue() {
console.log('Computing...');
let result = 0;
for (let i = 0; i < 1000000000; i++) {
result += i;
}
return result;
}
Even though data only uses the initial value, computeExpensiveValue() runs on every render.
The Good: Lazy Initialization
function ExpensiveComponent() {
// Good: Function only runs once
const [data, setData] = useState(() => computeExpensiveValue());
return <div>{data}</div>;
}
By passing a function, React only calls it on the initial render.
Another Example: Reading from localStorage
// Bad: Reads localStorage on every render
function UserPreferences() {
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');
return <div className={theme}>...</div>;
}
// Good: Reads localStorage only once
function UserPreferences() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') || 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
return <div className={theme}>...</div>;
}
Anti-Pattern 7: Boolean State Explosion
The Bad
function DataFetcher() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isEmpty, setIsEmpty] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
async function fetchData() {
setIsLoading(true);
setIsError(false);
setIsSuccess(false);
setIsEmpty(false);
try {
const data = await fetch('/api/data').then(r => r.json());
if (data.length === 0) {
setIsEmpty(true);
} else {
setIsSuccess(true);
}
} catch (error) {
setIsError(true);
} finally {
setIsLoading(false);
}
}
if (isLoading) return <Spinner />;
if (isError) return <Error />;
if (isEmpty) return <Empty />;
if (isSuccess) return <Data />;
}
Why It’s Bad
- Four boolean flags for mutually exclusive states
- Easy to get into invalid state (e.g., both
isErrorandisSuccesstrue) - Forgot to reset a flag? Bugs.
The Good: State Machine
function DataFetcher() {
const [state, setState] = useState({ status: 'idle', data: null, error: null });
async function fetchData() {
setState({ status: 'loading', data: null, error: null });
try {
const data = await fetch('/api/data').then(r => r.json());
if (data.length === 0) {
setState({ status: 'empty', data: [], error: null });
} else {
setState({ status: 'success', data, error: null });
}
} catch (error) {
setState({ status: 'error', data: null, error });
}
}
switch (state.status) {
case 'idle':
return <button onClick={fetchData}>Load Data</button>;
case 'loading':
return <Spinner />;
case 'error':
return <Error message={state.error.message} />;
case 'empty':
return <Empty />;
case 'success':
return <Data items={state.data} />;
default:
return null;
}
}
Now it’s impossible to be in two states at once.
Using a Library
For complex state machines, use XState or similar:
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
on: {
SUCCESS: 'success',
ERROR: 'error',
EMPTY: 'empty'
}
},
success: { on: { FETCH: 'loading' } },
error: { on: { FETCH: 'loading' } },
empty: { on: { FETCH: 'loading' } }
}
});
function DataFetcher() {
const [state, send] = useMachine(fetchMachine);
async function fetchData() {
send('FETCH');
try {
const data = await fetch('/api/data').then(r => r.json());
send(data.length === 0 ? 'EMPTY' : 'SUCCESS');
} catch {
send('ERROR');
}
}
// Render based on state.matches()
}
Summary: Quick Reference for Code Reviews
When reviewing React code, flag these patterns:
| ❌ Bad | ✅ Good |
|---|---|
| Storing computed values in state | Compute during render or use useMemo |
| Mutating state directly | Create new objects/arrays |
| Using state for non-render values | Use useRef |
| Many related state variables | Use useReducer |
setState(value) in async code | Use setState(prev => ...) |
| Expensive initialization | Use useState(() => ...) |
| Multiple boolean flags | Use state machines |
Conclusion
useState is powerful but easy to misuse. These anti-patterns lead to bugs, performance issues, and confusing code.
As a reviewer:
- Look for derived state that can be computed
- Check that state updates are immutable
- Question whether state is needed vs refs
- Suggest
useReducerfor complex related state - Watch for stale closures in effects and timeouts
- Ensure lazy initialization for expensive operations
- Push back on boolean state explosion
Your future self (and your teammates) will thank you for catching these early.