Every React codebase I've audited over the last three years had the same 10 problems. Not different problems on different teams — the same 10. They cost real money: extra renders that drain mobile batteries, useEffect bombs that race and double-fetch, bundle bloat that adds full seconds to Largest Contentful Paint, accidental O(n²) updates that freeze typing inputs.
This is not "the 10 most clever React patterns." It's the 10 mistakes that show up over and over, with the exact before/after code, the production metrics from a real SaaS dashboard (a multi-tenant analytics platform with 800+ components, 18,000 LOC of TSX, 240k monthly active users), and the diagram for why each one matters.
This is a condensed cross-post. The full version — with every diagram, the diagnostic checklist, and the complete code — lives on my blog: Top 10 Mistakes React Developers Make in 2026.
TL;DR
| # | Mistake | Cost when wrong | Cost when fixed |
|---|---|---|---|
| 1 | Storing derived state with useState + useEffect
|
Double renders, bugs on every dep change | 0 extra renders |
| 2 | Fetching inside useEffect (waterfalls) |
LCP 4.2s | LCP 1.4s |
| 3 | Stale closures + missing useEffect deps |
Random heisenbugs in prod | Predictable behaviour |
| 4 | Index as key in dynamic lists |
Wrong rows highlighted; lost input state | Stable rows |
| 5 | Context for high-frequency state | Whole tree re-renders / keystroke | One subscriber re-renders |
| 6 | Premature useMemo / useCallback
|
Slower, harder to read | No churn |
| 7 | State updates fired during render | Infinite render loop in prod | Clean control flow |
| 8 | Direct DOM mutation that fights React | Reconciler resets your DOM | Refs + state in sync |
| 9 | Giant components that re-render on every keystroke | INP 380ms; typing visibly lags | INP 80ms |
| 10 | Barrel imports + no code splitting | JS bundle 720 KB | JS 230 KB |
Real production aggregate after fixing all 10:
- LCP 4.2s → 1.4s (mobile, p75)
- INP 380ms → 80ms (the biggest UX win)
- JS bundle 720 KB → 230 KB
- Re-renders per keystroke in main view: 47 → 3
- Sentry React errors / week: 84 → 11
- "App feels slow" support tickets: 31/month → 4/month
The fixes were not exotic. They were 10 boring habits applied consistently.
Mistake #1 — Storing derived state with useState + useEffect
This is the #1 mistake in every React codebase, full stop.
// ❌ store, then sync via effect
function CampaignsList({ campaigns, search }) {
const [filtered, setFiltered] = useState([]);
useEffect(() => {
setFiltered(campaigns.filter(c => c.name.toLowerCase().includes(search.toLowerCase())));
}, [campaigns, search]);
return <Table rows={filtered} />;
}
This renders twice on every prop change, shows a stale filter on first paint, and gives you two sources of truth.
// ✅ derive, don't store
const filtered = useMemo(
() => campaigns.filter(c => c.name.toLowerCase().includes(search.toLowerCase())),
[campaigns, search],
);
Metric: /campaigns re-renders per keystroke: 6 → 2. INP on filtering: 220ms → 90ms.
The rule: If you can compute it from props/state during render, do that. State is for things React can't derive.
Mistake #2 — Fetching inside useEffect (the waterfall trap)
// ❌ fetch on mount in every component → sequential waterfall
useEffect(() => { fetch('/api/me').then(r => r.json()).then(setUser); }, []);
Each fetch waits for the previous one and a render before it can even start.
// ✅ Server Component, parallel fetches
export default async function DashboardPage() {
const user = await getUser();
const [kpis, events, revenue] = await Promise.all([
getKpis(user.id), getRecentEvents(user.id), getRevenue(user.id),
]);
return <><KpiCards data={kpis} /><RecentEvents data={events} /><RevenueChart data={revenue} /></>;
}
On a SPA, use TanStack Query with useQueries for parallel requests.
Metric: /dashboard LCP on 3G: 4,200ms → 1,400ms.
The rule: Don't fetch in components if you can fetch on the server. Don't fetch sequentially if you can fetch in parallel.
Mistake #3 — Stale closures and missing useEffect deps
// ❌ counter that "freezes"
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onmessage = () => setCount(count + 1); // ← stale `count`
return () => ws.close();
}, []); // ← missing dep
// ✅ functional setState — always sees the latest value
ws.onmessage = () => setCount(c => c + 1);
Turn react-hooks/exhaustive-deps to error, not warn.
Metric: "X stopped updating after Y action" bugs in Sentry: 23 → 1.
Mistake #4 — Index as key in dynamic lists
{campaigns.map((c, i) => <CampaignRow key={i} campaign={c} />)} // ❌
{campaigns.map(c => <CampaignRow key={c.id} campaign={c} />)} // ✅
When you delete a row, index keys make React reuse the wrong DOM nodes — inputs jump, focus lands wrong, animations replay. Index keys are safe only when the list never reorders, never has middle inserts/removes, and items hold no internal state.
Metric: row-state bugs ("wrong toggle flipped after sort"): 9 → 0.
Mistake #5 — Context for high-frequency state
Context re-renders all consumers on any value change. Put mousePos (60×/sec) in a shared context and your whole app re-renders 60 times a second.
// ✅ for many high-frequency values, use a subscribable store
const useStore = create((set) => ({
cart: { items: [] },
setMousePos: (pos) => set({ mousePos: pos }),
}));
function CartBadge() {
const itemCount = useStore(s => s.cart.items.length); // re-renders only when count changes
return <Badge>{itemCount}</Badge>;
}
Metric: /inbox re-renders per second: 62 → 4 after moving to Zustand slices.
The rule: Context for low-frequency global values (theme, user, locale). Subscribable store for anything that updates more than once a second.
Mistake #6 — Premature useMemo and useCallback
useMemo has overhead. For a cheap string concat, the memo is slower than just computing it. Memoize only when (1) the computation is genuinely expensive, (2) the value is a dep of another hook, or (3) you're passing it to a React.memo'd child.
Metric: removed 84 useMemo + 67 useCallback calls → dashboard render time down 11%.
The rule: Default to no memo. Add it only when a profile shows the cost is real.
Mistake #7 — Updating state during render
// ❌ setState in the component body → second render / infinite loop
if (team && !name) setName(team.name);
// ✅ derive a default; let the user override
const [draft, setDraft] = useState(null);
const name = draft ?? team?.name ?? '';
return <input value={name} onChange={e => setDraft(e.target.value)} />;
The rule: Render must be pure. Never mutate state from the component body.
Mistake #8 — Fighting React with direct DOM manipulation
// ❌ querySelector + style mutation gets undone on re-render
const el = document.querySelector('.message-list');
if (el) el.scrollTop = el.scrollHeight;
// ✅ ref-based, scoped, robust
const ref = useRef(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
Direct DOM is fine for reading layout (getBoundingClientRect from a ref), focus management, and animation libs. Never for toggling classes/text React also manages.
Metric: /inbox scroll glitches: 5 reports/week → 0.
Mistake #9 — Giant components that re-render on every keystroke
When a search input lives in the same component as a 5,000-row table, every keystroke re-renders the table, the filters, and the pagination.
Three layered fixes: (a) isolate the input's state in a small component, (b) useDeferredValue for the expensive consumer, (c) virtualize the table.
const deferredSearch = useDeferredValue(search); // input updates now; table catches up
const filtered = useMemo(() => campaigns.filter(c => c.name.includes(deferredSearch)), [campaigns, deferredSearch]);
Metric: /campaigns INP: 380ms → 80ms. Re-renders per keystroke: 47 → 3.
Mistake #10 — Barrel imports + no code splitting
import { Button, Card } from '@/components'; // ❌ may pull the entire UI library
import { Button } from '@/components/button'; // ✅ leaf import
Diagnose with npx @next/bundle-analyzer. Leaf-import; dynamically import heavy below-the-fold components; swap heavy libs (moment → date-fns, lazy-load chart.js/mapbox-gl).
Metric: home route JS 720 KB → 230 KB, LCP 4.0s → 1.6s.
The aggregate (after fixing all 10)
| Metric | Before | After | Improvement |
|---|---|---|---|
| LCP (mobile p75) | 4.2s | 1.4s | −67% |
| INP (overall p75) | 380ms | 80ms | −79% |
| Home route JS | 720KB | 230KB | −68% |
| Re-renders per keystroke | 47 | 3 | −94% |
| Sentry React errors / week | 84 | 11 | −87% |
| CrUX "Core Web Vitals: Good" | 41% | 89% | +117% |
Three weeks of work. No new features. Trial-signup conversion rose 14%. Speed converts.
Three habits that prevent 90% of the pain:
- Default to derivation over state. Store the minimum; derive the rest.
- Profile before you optimize. React DevTools Profiler, Lighthouse, bundle analyzer — not vibes.
-
Make linting strict.
react-hooks/exhaustive-deps: 'error'.
Read the full version with every diagram and the complete diagnostic checklist on PrepStack. Auditing a React codebase that's slowing down? Drop the page URL in the comments — happy to point at which of these 10 is likely the culprit.
Top comments (0)