React Architecture Gotchas & Common Mistakes
A field guide to the most common mistakes React architects and tech leads make on real projects - grouped by area and ordered from foundational to specific. Use it as a pre-launch review, a code-audit checklist, or an onboarding doc for senior hires.
- Walk each item - if you can't confidently say "we don't do this," add a ticket.
- Don't treat it as a style guide - these are architectural failure modes, not lint rules.
- Revisit at every milestone. The mistakes that hurt at MVP are different from the ones that hurt at scale.
-
Global state by default - Reaching for Redux, Zustand, or a global atom the moment two components share data.
- Why it happens: Leads assume "shared = global" and skip local state, lifted state, and URL state as options.
- Fix: Start with local state; lift only when a sibling truly needs it; promote to global only when 3+ unrelated branches read it.
-
Context for hot-updating values - Putting form state, counters, or cursor position in a Context provider.
- Why it happens: "Context solves prop drilling" gets overgeneralized.
- Fix: Context is for low-frequency, broadly-read values (theme, auth user, locale). For hot-updating values, use Zustand, Jotai, or local state.
-
Fetching in useEffect - Manual fetch + setState + loading/error booleans in every component.
- Why it happens: It's what most tutorials still show.
- Fix: Use TanStack Query, SWR, or RSC. Caching, dedup, retries, and error handling are solved problems.
-
No cache invalidation story - Mutations succeed but the list view still shows stale data.
- Why it happens: Teams wire up a data library but never codify invalidation conventions.
- Fix: Define a query-key taxonomy up front and use
invalidateQueries/mutate consistently on every mutation.
-
Client-only fetching in Next.js App Router - Every page starts with "use client" and fetches on the client.
- Why it happens: Muscle memory from Pages Router or SPA days.
- Fix: Default to server components; fetch on the server; drop to client only for interactivity.
-
URL state stored in React state - Filters, tabs, pagination, and dialog-open flags kept in useState.
- Why it happens:
useState is the shortest path.
- Fix: Put shareable/back-forward-able state in the URL (
searchParams, route params). Reserve useState for truly ephemeral UI state.
-
Premature abstraction - Extracting a "reusable" component after the second usage, locking in a bad API.
- Why it happens: DRY dogma and the urge to look tidy.
- Fix: Wait for the third real use. Three similar copies are cheaper than one wrong abstraction.
-
Wrappers over wrappers - Custom Button wraps shadcn Button wraps Radix Button - no one knows which props hit the DOM.
- Why it happens: Teams hedge every third-party API "just in case."
- Fix: Use third-party components directly unless you have a concrete reason to wrap. When you do wrap, re-export the underlying type with
ComponentPropsWithoutRef.
-
Prop drilling ignored - A prop passes through 6 components that don't use it.
- Why it happens: Refactoring prop chains feels risky.
- Fix: Use composition (
children, slot props), a colocated Context, or promote the consumer up the tree.
-
Over-memoization - useMemo, useCallback, and React.memo sprinkled everywhere.
- Why it happens: Cargo-culted as "performance best practice."
- Fix: Memoize only after profiling a real re-render problem. Memoization has a cost - both runtime and cognitive.
-
Under-memoization of actual hot paths - A 10,000-row table re-renders every cell on every keystroke.
- Why it happens: "React is fast" gets overgeneralized; profiling is skipped.
- Fix: Profile with React DevTools, virtualize long lists, split context, and memoize the actual hot component.
-
Massive page components - 600-line page files doing fetching, state, layout, and business logic.
- Why it happens: It starts as 80 lines and grows one feature at a time.
- Fix: Extract feature modules with a clear public API. A page should mostly orchestrate - not implement.
-
"use client" at the top of the tree - Marking the root layout as client pulls the entire tree into the client bundle.
- Why it happens: One child needs a hook and the lead adds
"use client" to the nearest ancestor.
- Fix: Push
"use client" as deep as possible - isolate it to the leaf that actually uses hooks/events.
-
Treating RSC like SSR - Expecting useState or useEffect in a server component.
- Why it happens: The mental model from Pages Router carries over.
- Fix: Server components are a different primitive - no hooks, no state, no event handlers. Learn the boundary before writing code.
-
Serialization surprises - Passing a class instance, function, or Date-wrapping object from server to client component.
- Why it happens: Types compile fine; the error shows up at runtime.
- Fix: Pass plain serializable data (strings, numbers, arrays, POJOs). Re-construct class instances in the client component if needed.
-
Mixing Pages Router patterns in App Router - getServerSideProps, getStaticProps, or _app.tsx conventions bleeding into app/.
- Why it happens: Tutorials, Stack Overflow, and old LLM training data all assume Pages Router.
- Fix: Read
node_modules/next/dist/docs/ directly. Heed deprecation notices. Don't copy-paste from pre-2024 sources.
-
Data waterfalls in nested server components - Sequential awaits that block rendering.
- Why it happens: Each component fetches its own data, unaware of siblings.
- Fix: Parallelize with
Promise.all, hoist fetches to a shared parent, or use route-level loading.tsx + streaming.
-
No bundle budget - No CI check on JS size; every dependency silently grows the bundle.
- Why it happens: "We'll deal with it later."
- Fix: Add a size budget to CI (e.g.,
@next/bundle-analyzer, size-limit) and fail PRs that exceed it.
-
Barrel file tree-shaking traps - Importing from index.ts that re-exports everything.
- Why it happens: Barrels feel clean at development time.
- Fix: Import from the source path (
lib/date/format) or audit that your bundler actually tree-shakes your barrels (many don't reliably).
-
Client components for static content - Marketing pages marked "use client" for a single animation.
- Why it happens: One interactive widget pulls the whole page into the client bundle.
- Fix: Keep the page as a server component; render the interactive widget as a small client island.
-
No Core Web Vitals monitoring - Shipping without LCP/INP/CLS telemetry.
- Why it happens: Synthetic tests pass; real-user metrics are an afterthought.
- Fix: Wire Vercel Analytics,
web-vitals + your analytics provider, or PostHog Web Vitals from day one.
-
Testing implementation details - Tests assert on setState calls, internal method names, or private state.
- Why it happens: Enzyme-era habits and snapshot-testing overuse.
- Fix: Test user-visible behavior with Testing Library. Assert on what the user sees and does, not how state updates.
-
Mocking the network in integration tests - jest.mock("axios") stubs scattered across the suite.
- Why it happens: MSW feels like more setup up front.
- Fix: Use MSW (Mock Service Worker) once - real network layer, realistic handlers, shared across unit and E2E.
-
No error boundaries - One runtime error unmounts the whole app.
- Why it happens: Error boundaries feel like a "we'll add it later" concern.
- Fix: Wrap the root layout, each route segment, and any risky widget (charts, third-party embeds) in error boundaries with sensible fallbacks.
-
No accessibility budget - Keyboard nav never tested; axe-core / jsx-a11y never enabled.
- Why it happens: A11y gets reprioritized behind features until a compliance audit.
- Fix: Turn on
eslint-plugin-jsx-a11y on day one, run axe-core in E2E, and test keyboard navigation as part of PR review.
-
Stack choices with no ADR - Every new hire re-litigates "why not Remix?"
- Why it happens: Decisions live in Slack threads that scroll into oblivion.
- Fix: Commit architecture decision records (ADRs) to
docs/adr/ with context, decision, consequences, and a revisit date.
-
Monorepo as a silver bullet - Turborepo/Nx adopted for one app and one shared package.
- Why it happens: FAANG influencer content makes monorepos sound universal.
- Fix: Stay single-repo until you have at least two deployable surfaces sharing non-trivial code. The overhead only pays off at that point.
-
Rewrite instead of migrate - "Let's rewrite it in Remix/TanStack Start/Next.js 15."
- Why it happens: Migrations feel slower than greenfield, and leads underestimate feature regression risk.
- Fix: Incrementally migrate behind a feature flag; prove parity route-by-route before retiring the old code.
-
DX debt - Slow type-check, slow HMR, flaky tests - ignored until team velocity collapses.
- Why it happens: DX doesn't have a PM pushing for it.
- Fix: Budget explicit DX time every quarter. Track
tsc --noEmit time, HMR latency, and test-suite duration as visible metrics.
-
Hiring for framework familiarity over fundamentals - A Next.js expert who can't explain reconciliation, keys, or memoization.
- Why it happens: Interviews over-index on the current hot framework.
- Fix: Interview for React mental model (rendering, state, effects, keys) and one senior JS/TS skill (types, async, perf). Framework APIs are learnable in a week; fundamentals are not.
- Tier 1 (1–6): State and data. Getting these wrong is the #1 cause of frontend tech debt - fix them first.
- Tier 2 (7–12): Component shape. Affects every new feature's velocity; address during any major refactor.
- Tier 3 (13–17): RSC / boundary hygiene. Critical on Next.js 15 / App Router - don't ship without reviewing.
- Tier 4 (18–21): Performance. Add budgets and telemetry before the bundle gets unfixable.
- Tier 5 (22–25): Quality & testing. Cheap to set up on a new codebase; expensive to retrofit.
- Tier 6 (26–30): Team & process. The highest-leverage items - but the easiest to postpone.