Lesson 3 of 8 · 55 min

Component architecture and state design

Decompose surfaces cleanly and place state in four buckets — server cache, URL, local UI, global UI — with prop contracts, composition, fetch/invalidation patterns, and failure-mode tables seniors defend in interviews.

God components fail interviews and production

Most mid-level FE codebases grow a 600-line route component that fetches, formats, handles modals, owns URL filters, and paints charts. Seniors decompose by responsibility and put each piece of state in the right bucket. This lesson is the architecture language for coding rounds and FE system design.
Decomposition is not “make more files.” It is drawing boundaries so data flows one way, re-renders stay local, and contracts are testable. Interviewers watch whether you invent six layers of abstraction or cut cleanly along change boundaries. The spine: user intent → URL/route → rendering strategy → server/client boundaries → state bucket → data fetch → hydration → interaction → a11y surface.

The four state buckets

Memorise and apply this taxonomy until it is reflex. Almost every state bug is a bucket mistake — two sources of truth, or globalising something local. Decision rule: server-owned → server cache; shareable/bookmarkable → URL; used by many distant trees → thin global UI; else local.
  1. 011. Server cache — remote data with identity, freshness, invalidation. TanStack Query / SWR / RSC payload cache. Not Redux-by-default.
  2. 022. URL state — shareable, back-buttonable UI: filters, tabs, pagination, selected id. useSearchParams / path params. The most under-used bucket.
  3. 033. Local UI state — ephemeral: open/closed, draft input, hover. useState / useReducer colocated with the widget.
  4. 044. Global UI state — rare cross-tree UI: toast queue, auth shell, theme. Zustand/Context — thin on purpose.
tsx
1// Decision rule2function decideBucket(s: { serverOwned?: boolean; shareable?: boolean; usedByMany?: boolean }) {3	if (s.serverOwned) return 'server'   // RSC + Query + Server Action4	if (s.shareable)   return 'url'      // ?q=&tab=5	if (s.usedByMany)  return 'global'   // thin store/context6	return 'local'7}89// Product list — bucket map10// server: products query key ['products', filtersFromUrl]11// URL: ?q=&page=&sort=  (source of truth for filters)12// local: isFilterDrawerOpen, draft form before Apply13// global: toast on mutation error; currentUser from auth shell1415function ProductsPage() {16	const sp = useSearchParams()17	const filters = parseFilters(sp) // URL bucket18	const { data } = useQuery({ queryKey: ['products', filters], queryFn: ... })19	const [drawerOpen, setDrawerOpen] = useState(false) // local20	return (...)21}

Architecture diagram (text)

text
1                   ┌──────────────────────┐2                   │   Server (RSC)       │3                   │  Server Actions      │4                   └──────────┬───────────┘5                              │ revalidateTag / Path6                              ▼7                   ┌──────────────────────┐8                   │   Cache (per-route)  │9                   └──────────┬───────────┘10        ┌─────────────────────┼─────────────────────┐11        ▼                     ▼                     ▼12  ┌──────────┐         ┌──────────┐          ┌──────────┐13  │   URL    │         │  Server  │          │  Local   │14  │  state   │         │  state   │          │  state   │15  │ ?q=foo   │         │ RQ / SWR │          │ useState │16  └────┬─────┘         └────┬─────┘          └────┬─────┘17       └─────────────────────┴─────────────────────┘18                              │19                              ▼ derived selectors (don't duplicate)

Refactoring the god component

Walk a live coding or take-home god file with this cut order: (1) extract presentational pieces with props, (2) lift data to route/loader/query, (3) move filters to URL, (4) leave local chrome state in leaf widgets, (5) only then consider global store for true cross-cutting UI.
text
1// Before: one file owns fetch + table + modal + filters2// After layers:3//  RouteProducts.server.tsx  — fetch / pass initial4//  ProductsView.tsx         — composition only5//  FiltersBar.tsx           — reads/writes URL6//  ProductsTable.tsx        — pure-ish props7//  RowActions.tsx           — local menu state8//  useProductMutation.ts    — server cache invalidation910// Measure: React Profiler before/after on filter keystrokes11// Goal: typing in search does not re-render table body work if deferred

Prop contracts and composition

Prefer composition (children, slots) over configuration mega-props. Headless primitives (Radix, React Aria, Base UI) separate behaviour/a11y from visual styling — use at design-system altitude, do not reimplement combobox focus logic from scratch in interviews unless asked. Discriminated unions for variant props are a TypeScript senior signal.
tsx
1type ButtonProps =2	| { kind: 'link'; href: string; onClick?: never }3	| { kind: 'button'; href?: never; onClick: () => void }45function Button(props: ButtonProps) {6	if (props.kind === 'link') return <a href="{props.href}">...</a>7	return ...8}9const schema = { pageSize: 20, sort: 'new' } satisfies Filters

When global UI state is wrong

  1. 01Putting form drafts in a global store → ghost state after navigate; lose “discard” semantics.
  2. 02Mirroring server entities in Zustand “for speed” → dual writes, stale bugs.
  3. 03Filter state only in memory → cannot share link to a filtered view.
  4. 04Modal open flags in Redux for every dialog → ceremony without benefit; local or URL (?modal=) is enough.
  5. 05Form state in global store → type safety loss, harder a11y, no natural unmount cleanup.
Interview line: “Global state is a privilege, not a default. I promote state only when two distant subtrees must share it and URL/server cache cannot.”

Data fetching architecture

Cache key design is API design: keys must include every input that changes the result (user, tenant, filters). Invalidation: precise tags beat “invalidate everything.” Optimistic updates need rollback on error and reconciliation with server ids. AbortController on unmount or query change prevents stale overwrites — pair with L7 autocomplete race lessons.
tsx
1useQuery({2	queryKey: ['invoices', tenantId, { status, page }],3	queryFn: ({ signal }) => api.listInvoices({ status, page }, { signal }),4})56useMutation({7	mutationFn: updateInvoice,8	onMutate: async (next) => {9		await qc.cancelQueries({ queryKey: ['invoices', tenantId] })10		const prev = qc.getQueryData(['invoices', tenantId])11		qc.setQueryData(['invoices', tenantId], optimistic(prev, next))12		return { prev }13	},14	onError: (_e, _n, ctx) => qc.setQueryData(['invoices', tenantId], ctx?.prev),15	onSettled: () => qc.invalidateQueries({ queryKey: ['invoices', tenantId] }),16})

Component taxonomy that interviewers recognise

  1. 01Route/container — data + URL; thin.
  2. 02Feature composition — arranges sections; little logic.
  3. 03Presentational — props in, events out; Storybook-friendly.
  4. 04Headless/primitive — behaviour + a11y; style elsewhere.
  5. 05Utility hooks — reusable state machines, not dump for leftover logic.

Co-location vs splitting

Co-locate by feature when only one route uses it. Split to a design-system package when three+ surfaces share visual/behaviour contracts. Premature shared packages create versioning pain; late extraction duplicates bugs. Interview answer: start co-located, extract on proven reuse + stable API. Kent C. Dodds’ state colocation guidance applies: state lives in the lowest component that needs it; lifting is a cost.

Re-render discipline without cargo cult

Profile first. Then: shrink state scope, stabilize identities at boundaries, virtualize large lists, defer heavy work. React.memo on everything is noise. State in the wrong bucket causes more wasted renders than missing memo. Architecture is a performance feature.

Mini design: notifications inbox state map

text
1URL:      /inbox?filter=unread&id=msg_123   // selection + filter shareable2SERVER:   useQuery(['inbox', filter]); useMutation markRead → invalidate3LOCAL:    composer draft, hover preview open4GLOBAL:   unread badge count in shell (or derived from query cache)56Trap: storing selected message only in useState → refresh loses deep link.7Trap: badge in isolated store not updated on markRead → dual truth.89Derived: unreadCount = selectFromQueryCache(inbox) — do not mirror in second store.

Failure modes table (architecture)

  1. 01Prop drilling 5+ levels — re-renders + maintenance. Fix: composition or thin context for static shell props.
  2. 02Global store for everything — whole-app re-renders. Fix: co-locate; URL for shareable; server for remote.
  3. 03Mirrored server state — stale/inconsistent. Fix: TanStack Query / RSC; never hand-mirror.
  4. 04Form state in global store — ghost drafts. Fix: local reducer or useActionState.
  5. 05URL state only in component state — share/back broken. Fix: searchParams as source of truth.
Building resilient frontend architecture — Monica LentConference talk

Interview answer bank — architecture

  1. 01Q: Where should pagination live? In the URL when shareable/backable (almost always for product lists), and in the server-cache query key derived from that URL. Local-only page state breaks refresh and sharing. Global store pagination is usually overkill and still needs URL sync if links matter.
  2. 02Q: When do you introduce a monorepo package boundary? When three or more surfaces share a stable visual/behaviour contract and you need independent versioning or ownership. Until then, co-locate by feature. Premature packages create versioning tax; late extraction duplicates bugs. Nx/Turborepo boundaries enforce the ownership story once reuse is proven.
  3. 03Q: How do you stop dual sources of truth for unread badges? Derive the badge from the same server-cache query that powers the inbox, or update both in one mutation onSettled path. An isolated global counter that is not updated on markRead is a classic dual-truth bug. Prefer selectors over mirrored numbers.
  4. 04Q: Container vs presentational — still useful? Yes at interview altitude: containers own data and URL; presentational components take props and emit events. Headless primitives sit under presentational styling. Do not invent six layers — two clean layers beat a god file and beat over-abstracted hexagons.
  5. 05Q: How do you design cache keys? Include every input that changes the result: tenant, user, filters, page/cursor, locale if relevant. Missing a dimension causes silent cross-user or cross-filter bleed. Treat the key as the identity of the result set — it is API design, not an implementation detail.
Microfrontends are an organizational tool, not a default architecture. Reach for module federation or separate deploys only when independent team release cycles dominate the cost of shared runtime complexity. For most product FE interviews, a modular monorepo with clear package boundaries scores higher than inventing MFE theatre for a five-screen app.
text
1// Feature folder shape (co-located)2features/invoices/3  api.ts              // fetchers + types4  useInvoices.ts      // query/mutation hooks5  InvoiceTable.tsx    // presentational-ish6  InvoiceFilters.tsx  // URL read/write7  index.ts            // public exports only89// Promote to packages/ui or packages/invoices when reuse ≥3 call sites10// and the public API is stable enough to semver.
Senior drill: take any screen you built last month and draw four boxes for state buckets. If two boxes hold the same fact, you have a bug waiting. If URL is empty but users share links, you have a product gap. If everything is in Zustand, you have a 2016 architecture on a 2026 stack.
docsTanStack Query — Important DefaultsTanStackdocsReact — Passing Data Deeply with Contextreact.devdocsReact — You Might Not Need an Effectreact.devdocsNext.js — File-system Conventionsnextjs.orgarticleMicro Frontends — Cam Jackson (martinfowler.com)Martin Fowler

Checkpoint

Product filters must be shareable via link and work with Back. Where does filter state live?

AZustand global store for “simplicity.”BURL search params as source of truth; derive query keys from them.COnly component useState in the filter drawer.
Sign up free to answer and see why

Checkpoint

When is a global client store the wrong choice?

ATheme toggle used by the whole shell.BCaching the /api/orders list for a table with filters and pagination.CToast notification queue.
Sign up free to answer and see why

Checkpoint

You profile a page: typing in a search box re-renders a 500-row table body every keystroke. Best first architecture fix?

ARewrite the table in Vue for finer reactivity.BKeep input state local (or deferred); drive the expensive query/list from deferred/URL-committed value; virtualize rows.CWrap every cell in React.memo with deep compare.
Sign up free to answer and see why

Checkpoint

Prop drilling through three layout levels for a user avatar. Best response?

AImmediately introduce Redux.BPass via layout composition or a thin Auth/user context at the shell — not a server-data store.CFetch /me in every leaf component.
Sign up free to answer and see why

Checkpoint

Optimistic mark-as-read fails on the server. What must the client do?

ALeave the optimistic state — user already saw it.BRoll back cache to previous snapshot and surface error UX; then reconcile with invalidation.CReload the entire application.
Sign up free to answer and see why

Can you bucket any UI’s state into server cache / URL / local / global and defend the map?

New to itGetting thereConfident

Takeaways

  • Four buckets: server cache, URL, local UI, global UI — one truth per concern.
  • URL for shareable/backable state; server cache for remote entities; local for chrome; global rarely.
  • Decompose god components by data and change boundaries, not by file count vanity.
  • Composition and headless primitives beat configuration mega-props.
  • Architecture placement fixes more re-render pain than blanket memo.

Next: Core Web Vitals, the rendering menu, and performance diagnosis that interviewers trust.

Sources

Free to read · better with Enzo

Learn it with Enzo

Save your progress, answer the checkpoints, and let Enzo quiz you on what you just read.