React is a powerful and flexible library. But that flexibility also makes it easy to fall into anti-patterns—habits that seem fine at first but hurt readability, maintainability, and performance over time.
In this post, we’ll walk through common pitfalls, show bad code examples, explain why they’re bad, and then demonstrate better approaches.
1. Overusing useState
and Scattering State
Bad: multiple related states separated
function ProfileForm() {
const [firstName, setFirstName] = React.useState('');
const [lastName, setLastName] = React.useState('');
const [age, setAge] = React.useState(0);
// and it keeps growing...
return (
<>
<input value={firstName} onChange={e => setFirstName(e.target.value)} />
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<input value={age} onChange={e => setAge(Number(e.target.value))} />
</>
);
}
Why is this bad?
- Update logic gets scattered across multiple setters.
- Hard to keep values consistent (e.g. resetting all fields at once).
- More states = more dependency management and risk of bugs.
Good: group related state in an object
function ProfileForm() {
const [profile, setProfile] = React.useState({ firstName: '', lastName: '', age: 0 });
const update = (key) => (e) => setProfile(p => ({
...p,
[key]: key === 'age' ? Number(e.target.value) : e.target.value
}));
return (
<>
<input value={profile.firstName} onChange={update('firstName')} />
<input value={profile.lastName} onChange={update('lastName')} />
<input value={profile.age} onChange={update('age')} />
</>
);
}
2. Monster Components
Bad: everything stuffed into one component
function TodoApp() {
const [text, setText] = React.useState('');
const [todos, setTodos] = React.useState([]);
const add = () => { if (text.trim()) setTodos(t => [...t, { id: Date.now(), text }]); setText(''); };
const remove = (id) => setTodos(t => t.filter(x => x.id !== id));
return (
<div>
<h1>Todos</h1>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={add}>add</button>
<ul>
{todos.map(t => <li key={t.id}><span>{t.text}</span><button onClick={() => remove(t.id)}>x</button></li>)}
</ul>
</div>
);
}
Why is this bad?
- Mixes input, list rendering, add/remove logic in one file.
- Hard to test or reuse pieces.
- Any small change risks breaking unrelated logic.
Good: split responsibilities into smaller components
const TodoInput = ({ text, setText, onAdd }) => (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={onAdd}>add</button>
</div>
);
const TodoList = ({ todos, onRemove }) => (
<ul>{todos.map(t => <li key={t.id}>{t.text}<button onClick={() => onRemove(t.id)}>x</button></li>)}</ul>
);
function TodoApp() {
const [text, setText] = React.useState('');
const [todos, setTodos] = React.useState([]);
const add = () => { if (text.trim()) setTodos(t => [...t, { id: crypto.randomUUID(), text }]); setText(''); };
const remove = id => setTodos(t => t.filter(x => x.id !== id));
return (
<div>
<h1>Todos</h1>
<TodoInput text={text} setText={setText} onAdd={add} />
<TodoList todos={todos} onRemove={remove} />
</div>
);
}
3. Components Doing Too Much Logic
Bad: fetching data directly inside a UI component
function Users() {
const [users, setUsers] = React.useState([]);
React.useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Why is this bad?
- Couples data fetching with UI rendering.
- Harder to test in isolation.
- Makes the component harder to reuse.
Good: move logic into a custom hook
function useUsers() {
const [users, setUsers] = React.useState([]);
React.useEffect(() => {
let aborted = false;
(async () => {
const r = await fetch('/api/users');
const data = await r.json();
if (!aborted) setUsers(data);
})();
return () => { aborted = true; };
}, []);
return users;
}
function Users() {
const users = useUsers();
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
4. Reversing Data Flow (Child Updating Parent)
Bad: child updates parent’s state directly
function Child() {
// Imagine directly importing parent’s setter or using context incorrectly
// ❌ breaks one-way data flow
return null;
}
Why is this bad?
- Breaks React’s one-way data flow.
- Debugging becomes painful because updates happen in unexpected places.
- Creates tight coupling.
Good: Lift state up, pass down via props
function Parent() {
const [value, setValue] = React.useState('');
return <Child value={value} onChange={setValue} />;
}
const Child = ({ value, onChange }) => (
<input value={value} onChange={e => onChange(e.target.value)} />
);
5. Storing Derived State
Bad: storing values you could calculate
function Cart({ items }) {
const [total, setTotal] = React.useState(0);
React.useEffect(() => {
setTotal(items.reduce((s, it) => s + it.price, 0));
}, [items]);
return <div>Total: {total}</div>;
}
Why is this bad?
- Two sources of truth → data inconsistency risk.
- Extra state → extra re-renders.
- Harder to debug.
Good: compute when needed
function Cart({ items }) {
const total = React.useMemo(() => items.reduce((s, it) => s + it.price, 0), [items]);
return <div>Total: {total}</div>;
}
6. Abusing useEffect
for Calculations
Bad: using useEffect + setState just for filtering
function Filtered({ list, q }) {
const [filtered, setFiltered] = React.useState([]);
React.useEffect(() => {
setFiltered(list.filter(x => x.includes(q)));
}, [list, q]);
return <ul>{filtered.map(x => <li key={x}>{x}</li>)}</ul>;
}
Why is this bad?
- Triggers extra render cycles.
- Makes simple logic look like a side effect.
- Harder to reason about.
Good: useMemo for derived data
function Filtered({ list, q }) {
const filtered = React.useMemo(() => list.filter(x => x.includes(q)), [list, q]);
return <ul>{filtered.map(x => <li key={x}>{x}</li>)}</ul>;
}
7. Wrong key
Usage
Bad: using index as key
items.map((item, i) => <li key={i}>{item.name}</li>);
Why is this bad?
- When list order changes, React can’t track items correctly.
- Leads to bugs like losing focus or input values jumping around.
Good: use stable, unique keys
items.map(item => <li key={item.id}>{item.name}</li>);
8. Forgetting Cleanup in Effects
Bad: leaking event listeners
function WindowSize() {
const [w, setW] = React.useState(window.innerWidth);
React.useEffect(() => {
const onResize = () => setW(window.innerWidth);
window.addEventListener('resize', onResize);
// ❌ forgot to remove
}, []);
return <div>{w}</div>;
}
Why is this bad?
- Causes memory leaks.
- Can trigger updates after unmount → warnings/errors.
Good: return cleanup
function WindowSize() {
const [w, setW] = React.useState(window.innerWidth);
React.useEffect(() => {
const onResize = () => setW(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <div>{w}</div>;
}
9. Defining Components Inside Components
Bad: redefining child every render
function Parent({ items }) {
const Item = ({ item }) => <li>{item.name}</li>; // ❌ new component each time
return <ul>{items.map(i => <Item key={i.id} item={i} />)}</ul>;
}
Why is this bad?
- Creates a new function reference on every render.
- Memoization breaks, causing unnecessary re-renders.
- Harder to debug.
Good: define outside, memoize if needed
const Item = React.memo(({ item }) => <li>{item.name}</li>);
function Parent({ items }) {
return <ul>{items.map(i => <Item key={i.id} item={i} />)}</ul>;
}
10. No Error Boundaries
Bad: one error kills the entire app
function App() {
return <ProblemChild />; // throw → blank screen
}
Why is this bad?
- Terrible UX: user sees nothing.
- Errors don’t get logged or handled gracefully.
- Can’t recover or retry.
Good: wrap with an Error Boundary
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<ProblemChild />
</ErrorBoundary>
);
}
11. Ignoring "State is a Snapshot" (Stale Closures)
Bad: using outdated state inside interval
function Counter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
console.log('tick uses stale count:', count); // ❌ stale value
setCount(c => c + 1); // functional update works
}, 1000);
return () => clearInterval(id);
}, []); // missing dependency
return <div>{count}</div>;
}
Why is this bad?
- Closures capture old state.
- Leads to confusing, inconsistent logs and behaviors.
Good: use refs or functional updates
function Counter() {
const [count, setCount] = React.useState(0);
const ref = React.useRef(count);
React.useEffect(() => { ref.current = count; }, [count]);
React.useEffect(() => {
const id = setInterval(() => {
console.log('fresh count:', ref.current);
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
12. Wrong Dependency Arrays in useEffect
Bad: missing dependencies
function Search({ query, fetcher }) {
const [result, setResult] = React.useState(null);
React.useEffect(() => {
fetcher(query).then(setResult);
}, []); // ❌ ignores query and fetcher
return <div>{JSON.stringify(result)}</div>;
}
Why is this bad?
- Effect doesn’t rerun when
query
changes → stale data. - Bugs that only appear in specific cases, hard to trace.
Good: declare dependencies properly
function Search({ query }) {
const [result, setResult] = React.useState(null);
const fetcher = React.useCallback(
(q) => fetch(`/api?q=${encodeURIComponent(q)}`).then(r => r.json()),
[]
);
React.useEffect(() => {
let ignore = false;
fetcher(query).then(r => { if (!ignore) setResult(r); });
return () => { ignore = true; };
}, [query, fetcher]);
return <div>{JSON.stringify(result)}</div>;
}
Takeaways
To write cleaner, more maintainable React code:
- Keep state minimal and consistent.
- Follow the single responsibility principle.
- Use
useEffect
only for syncing with the outside world. - Always provide proper keys and error boundaries.
- Treat state as a snapshot per render, not as a mutable variable.
These small improvements add up to code that’s easier to debug, test, and evolve. 🚀
Top comments (0)