React is approachable, which makes it easy to start — and easy to pick up bad habits. The mistakes below are practical, common, and fixable. For each one I show what goes wrong, why it matters, and a concrete fix you can use immediately.
1) Mutating state instead of treating it as immutable
The problem
Beginners often modify arrays or objects in place (push, splice, direct property assignment). React’s state updates rely on identity changes. Mutating state in place can make UI updates unpredictable.
Bad example
// Bad: mutating state directly
function Todos() {
const [todos, setTodos] = useState([])
function add(todo) {
todos.push(todo) // <-- mutation
setTodos(todos) // React may not re-render
}
return null
}
Fix
- Always create new objects/arrays when updating state.
- Use spread, concat, or functional updates when the new state depends on previous state.
// Good: create a new array
function Todos() {
const [todos, setTodos] = useState([])
function add(todo) {
setTodos(prev => ([...prev, todo]))
}
return null
}
Why it helps
New references guarantee React detects changes and re-renders predictably.
2) Missing dependencies in useEffect (stale closures)
The problem
useEffect closures capture variables at the time they run. Forgetting to include dependencies causes stale values or duplicated effects.
Bad example
// Bad: missing dependency causes stale count
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1) // captures initial count = 0
}, 1000)
return () => clearInterval(id)
}, []) // missing [count]
return null
}
Fixes
Option A — include dependencies (safe, but may recreate timers)
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
Option B — use functional updates to avoid depending on stale values (preferred for intervals)
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
Why it helps
Including correct dependencies or using functional updates prevents subtle bugs and ensures effects run with correct values.
3) Using array index as key in lists
The problem
Using the array index for key (key={index}) is easy but dangerous when list items are inserted, removed, or reordered. It can break component state, animations, and form inputs.
Bad example
<ul>
{items.map((item, i) => (
<li key={i}>{item.name}</li>
))}
</ul>
Fix
Use stable IDs (database id, uuid, or otherwise stable unique identifier).
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
Why it helps
Stable keys let React reconcile items correctly and preserve local component state.
4) Overusing useEffect for things that should be in event handlers or derived from props/state
The problem
useEffect is for side effects — fetching, subscriptions, DOM mutations. It’s often misused for state derivation or logic that should happen inside callbacks or during render.
Bad example
// Bad: deriving filtered list in an effect and storing it
useEffect(() => {
setFiltered(items.filter(i => i.active))
}, [items])
Fix
Derive values during render or memoize them with useMemo.
const filtered = useMemo(() => items.filter(i => i.active), [items])
Why it helps
Keeps effects reserved for real side effects and simplifies your data flow.
5) Unnecessary re-renders due to anonymous functions and inline objects
The problem
Passing inline functions or objects into child props makes props change every render, which can trigger re-renders even when data hasn’t changed.
Bad example
<Child onClick={() => doSomething(item)} style={{ color: 'red' }} />
Fix
- Use useCallback for functions you want to preserve between renders
- Use useMemo for objects
- Or lift logic into the child or create stable handlers per item
const handleClick = useCallback((id) => doSomething(id), [doSomething])
const style = useMemo(() => ({ color: 'red' }), [])
<Child onClick={() => handleClick(item.id)} style={style} />
Why it helps
Reduces wasted renders and makes memoization (React.memo) effective.
6) Not cleaning up subscriptions / listeners
The problem
Adding listeners or subscriptions in effects without cleaning them up causes memory leaks and duplicate handlers.
Bad example
useEffect(() => {
window.addEventListener('resize', onResize)
}, []) // no cleanup
Fix
Always return a cleanup function from effects.
useEffect(() => {
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [onResize])
Why it helps
Prevents leaks and avoids multiple handlers accumulating over time.
7) Confusing controlled vs uncontrolled inputs
The problem
Switching between controlled (value/onChange) and uncontrolled (defaultValue/ref) modes can cause warnings or unexpected behavior.
Bad example
// Bad: sometimes value is undefined which switches modes
<input value={maybeUndefined} onChange={e => setVal(e.target.value)} />
Fix
Always provide a defined value for controlled inputs (use empty string fallback), or use refs for uncontrolled components.
<input value={val ?? ''} onChange={e => setVal(e.target.value)} />
Why it helps
Keeps input behavior predictable and prevents React warnings.
8) Not handling async errors (and missing loading state)
The problem
Not handling loading and error states when fetching makes the UI fragile and hard to debug.
Fix
Always model loading, error, and success states. Prefer a data-fetching library (React Query, SWR) for real projects.
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
let mounted = true
setLoading(true)
fetch('/api/items')
.then(r => r.json())
.then(json => mounted && setData(json))
.catch(err => mounted && setError(err))
.finally(() => mounted && setLoading(false))
return () => { mounted = false }
}, [])
Why it helps
Clear UX during fetches and safer state updates when components unmount.
9) Reinventing data fetching and caching
The problem
Writing a bespoke data-fetching layer misses lots of hard problems: caching, deduping requests, background refetch, retries, pagination.
Fix
Adopt a battle-tested library: React Query, SWR, or Apollo (GraphQL). They dramatically reduce complexity and bugs.
Why it helps
You get caching, stale-while-revalidate, background updates, and simpler components.
10) Not using error boundaries for unexpected runtime errors
The problem
If a child component throws, it can unmount the whole React tree (in practice this results in an error overlay in dev or broken UI in prod).
Fix
Use an Error Boundary (class component) or a library that provides one.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error) { return { error } }
render() {
if (this.state.error) return <div>Something went wrong</div>
return this.props.children
}
}
Why it helps
Error boundaries contain failures and give you a chance to display fallback UI and log errors.
11) Not splitting logic into hooks and components (monolithic components)
The problem
Large components with many responsibilities are hard to test and reason about.
Fix
Extract reusable logic into custom hooks (useTodos, useFetch) and smaller presentational components.
Why it helps
Improves reuse, testability, and clarity.
Quick checklist for cleaner React code
- Treat state as immutable — never mutate in place.
- Always declare correct useEffect dependencies; use functional updates to avoid stale closures.
- Use stable keys for lists.
- Prefer derived values during render or with useMemo, not stored state.
- Clean up subscriptions and event listeners in effects.
- Handle loading and error states for async code; consider React Query or SWR.
- Use useCallback/useMemo sparingly to prevent unnecessary renders — measure first.
- Use Error Boundaries in production apps.
- Split big components and extract custom hooks.
- Use TypeScript or prop-types to catch type-related mistakes early.
Tools and habits that help
- React DevTools (Profiler) — find expensive renders.
- ESLint with eslint-plugin-react-hooks — enforces effect dependencies.
- TypeScript — catches many runtime mistakes before you run the app.
- React Query / SWR — simplify data fetching concerns.
Final thoughts
Most React bugs follow simple patterns: accidental mutation, stale closures, and unstable identities (keys/props). Once you learn to think in terms of immutable state, pure render functions, and well-scoped effects, your components will be easier to reason about and much more reliable. Start small: enforce hooks rules in ESLint, avoid index keys, and adopt a data-fetching library — you’ll prevent far more bugs than you fix later.
