15 Common React Mistakes Developers Make (And How to Fix Them)
From stale closures in useEffect to prop drilling and key misuse—these React pitfalls show up in code review every week. Here is how to avoid them.
15 Common React Mistakes Developers Make (And How to Fix Them)
After years of reviewing React pull requests, I notice the same mistakes repeat across junior and senior codebases alike. They are rarely about syntax—they come from mental models that do not quite match how React schedules renders, reconciles trees, and runs effects. This article catalogs the mistakes I flag most often, explains why they bite, and shows fixes you can apply in your next refactor.
You do not need to memorize fifteen rules. Internalize the underlying patterns: state ownership, effect dependencies, referential stability, and where work belongs (event handler vs effect vs server).
Mistake 1: Treating useEffect as "on mount"
useEffect runs after paint when its dependencies change—not only once on mount unless the dependency array is empty, and even then Strict Mode may double-run in development.
// Bug: fetches on every id change but also encodes "mount-only" intent poorly
useEffect(() => {
fetchUser(id).then(setUser);
}, [id]);
// If you truly need once: document why and handle id changes explicitly
useEffect(() => {
let cancelled = false;
fetchUser(id).then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [id]);
Fix: Ask "what event causes this work?" If the answer is a button click, put logic in the handler. If the answer is "when id changes," useEffect is appropriate with correct dependencies.
Mistake 2: Missing or lying dependency arrays
Exhaustive-deps warnings exist because stale closures cause bugs that are hard to reproduce.
// Stale: always logs initial count
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, []); // eslint-disable here hides the bug
// Correct
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, [count]);
For callbacks you do not want to re-subscribe on every render, use useRef for mutable values or stabilize with useCallback when justified.
Mistake 3: Deriving state that should be computed
Duplicating props in state creates synchronization bugs.
// Wrong
function FullName({ first, last }) {
const [full, setFull] = useState(`${first} ${last}`);
useEffect(() => setFull(`${first} ${last}`), [first, last]);
return <span>{full}</span>;
}
// Right
function FullName({ first, last }) {
const full = `${first} ${last}`;
return <span>{full}</span>;
}
Use state only for user input or async results you cannot derive during render.
Mistake 4: Over-using useEffect for transformations
Filtering, sorting, and formatting belong in render or useMemo—not effects that call setState.
const filtered = useMemo(
() => items.filter((i) => i.name.includes(query)),
[items, query]
);
Mistake 5: Unstable keys in lists
Index keys break when items reorder, delete, or insert—React reuses the wrong component instance.
{items.map((item) => (
<TodoRow key={item.id} item={item} />
))}
Mistake 6: Creating objects and functions inline for memoized children
<MemoizedChart config={{ type: "line", color: "blue" }} />
Every parent render creates a new config reference → child re-renders. Hoist constants, memoize objects, or pass primitives.
Mistake 7: Prop drilling instead of composition
Before Context or Zustand, try component composition:
function Layout({ sidebar, children }) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
// Usage passes data where it is needed without intermediate layers
<Layout sidebar={<UserMenu user={user} />}>
<Dashboard />
</Layout>
Mistake 8: Context for high-frequency updates
Putting { cart, setCart, theme, setTheme } in one context value means any cart change re-renders theme consumers.
Split providers or use a store with selectors:
const useCartCount = () => useStore((s) => s.items.length);
Mistake 9: Mutating state directly
// Wrong
state.items.push(newItem);
setState(state);
// Right
setState({ ...state, items: [...state.items, newItem] });
With complex nested updates, consider Immer inside reducers or Zustand.
Mistake 10: Ignoring accessibility in interactive components
Custom dropdowns without keyboard support, missing labels, and div buttons are common. Use semantic HTML first; enhance with ARIA when building non-native widgets.
<button type="button" aria-expanded={open} aria-controls="menu-id">
Menu
</button>
Mistake 11: Fetching in useEffect without deduplication
Parallel mounts and Strict Mode can duplicate requests. Use TanStack Query, SWR, or Remix/Next.js loaders so caching and deduplication are centralized.
const { data, isLoading, error } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
Mistake 12: Not handling loading and error UI
Happy-path-only components fail in production networks. Co-locate skeletons and error boundaries with the feature.
Mistake 13: Giant components
When a file exceeds ~250 lines and mixes data fetching, form logic, and presentation, extract hooks and presentational components. Tests become easier; renders become more localized.
function useOrderForm(orderId) {
// state + handlers
return { fields, submit, isSubmitting };
}
function OrderFormView(props) {
// JSX only
}
Mistake 14: Fighting the controlled vs uncontrolled decision
Mixing value with defaultValue, or switching mid-lifecycle, causes cursor jumps and lost input. Pick controlled for validated forms; uncontrolled is fine for simple inputs with refs.
Mistake 15: Premature abstraction libraries
Reaching for Redux, MobX, or five context providers on day one adds complexity. Start with colocated state; introduce global tools when pain is proven across features.
Bonus pitfalls: forms, refs, and suspense
Resetting form state incorrectly
When a parent passes a new userId prop, a form may still show the previous user's input because React reuses the component instance. Keys force a clean mount:
<UserForm key={userId} userId={userId} />
Without key, you need controlled resets in useEffect—easy to get wrong and easy to fight with dirty-field tracking.
Refs during render
Reading or writing ref.current during render is undefined behavior in concurrent React. Set refs in effects or event handlers, or use callback refs when integrating non-React widgets.
Suspense without error boundaries
Throwing promises for data without a matching ErrorBoundary leaves users on a stuck fallback when the request fails. Pair route-level error.tsx (in Next.js) or component boundaries with retries and actionable error copy.
Assuming children always re-render with parents
React may bail out of subtrees when props are unchanged—but only when reconciliation allows it. Do not rely on "parent rendered, so child effect ran" unless you understand memo boundaries. Test behavior with Profiler instead of folklore.
Practical tips for code review
- Ask who owns this state? on every new
useState - Trace what triggers a re-render when performance complaints appear
- Require cleanup functions for subscriptions, timers, and abort controllers
- Prefer discriminated unions for async UI:
{ status: 'idle' | 'loading' | 'error' | 'success' }
Common mistakes when "fixing" mistakes
| "Fix" | Actual problem |
|---|---|
useEffect(() => {}, []) everywhere | Hides missing dependencies |
| Disabling Strict Mode | Masks unsafe effects |
key={Math.random()} | Forces remount, loses state, hurts perf |
| Global store for all UI state | Broad re-renders |
any in TypeScript | Removes compile-time guardrails |
Debugging habits that help
- React DevTools → Components → highlight updates
- Log render counts temporarily in suspicious components
- Reproduce in production build before optimizing
- Write a minimal test for the bug—especially effect and form bugs
Building habits that prevent regressions
Add a short "React checklist" to your PR template: effect dependencies verified, list keys stable, no duplicated derived state, loading/error states present for async UI. Pair it with TypeScript strict mode and ESLint react-hooks rules so the toolchain catches stale closures before humans do. Over time the same mistakes stop appearing because the defaults make the right thing easy—not because the team memorized fifteen blog posts.
Conclusion
React's API surface is small; the discipline is in how you structure state and side effects. Most recurring mistakes come from using effects as a catch-all, duplicating state, and spreading updates through the tree without a plan. Fix the mental model—colocate state, derive when possible, stabilize references intentionally, and fetch with proper caching—and the same issues stop reappearing in code review.
Pick two mistakes from this list that show up in your codebase today. Refactor one component each; the patterns will stick faster than reading another hooks cheat sheet.
Team conventions that prevent regressions
Adopt a short React style guide in your repo: where hooks live, naming for event handlers, when to extract components. Add ESLint rules for hooks dependencies and import order. In code review, ask “what re-renders when this state changes?” for every new context provider. Pair design system updates with performance spot checks on Storybook stories that mirror production data density. Mistakes return when conventions erode—refresh them during retros when bugs cluster around the same patterns.
Team conventions that prevent regressions
Adopt a short React style guide in your repo: where hooks live, naming for event handlers, when to extract components. Add ESLint rules for hooks dependencies and import order. In code review, ask “what re-renders when this state changes?” for every new context provider. Pair design system updates with performance spot checks on Storybook stories that mirror production data density. Mistakes return when conventions erode—refresh them during retros when bugs cluster around the same patterns.
Workshop: apply this week
Pick one idea from this article and ship it before Friday. Write a short internal note explaining what changed, what metric you expect to move, and how you will verify the result. Share the note with your team so the learning compounds. If the experiment fails, document the failure mode—it is as valuable as success for the next engineer reading this guide.
Frequently asked questions
- Why does my useEffect run twice in development?
- React Strict Mode intentionally double-invokes certain lifecycles in development to surface unsafe side effects. It does not happen in production builds. Fix effect cleanup instead of disabling Strict Mode.
- Is it wrong to use array index as a key?
- Index keys are acceptable for static lists that never reorder, filter, or insert items. For dynamic lists, use stable unique IDs from your data to avoid incorrect component state and unnecessary DOM updates.
- When should I lift state up versus use context?
- Lift state to the nearest common ancestor that needs it. Reach for context when many distant descendants need the same rarely-changing data (theme, locale). For frequently updating state, context often causes broad re-renders—consider colocation or a store with selectors.
Comments
Discussion is coming soon. Share this article and join the conversation on social media.
Enjoyed this article?
Get weekly engineering guides delivered to your inbox.