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
The four state buckets
- 011. Server cache — remote data with identity, freshness, invalidation. TanStack Query / SWR / RSC payload cache. Not Redux-by-default.
- 022. URL state — shareable, back-buttonable UI: filters, tabs, pagination, selected id. useSearchParams / path params. The most under-used bucket.
- 033. Local UI state — ephemeral: open/closed, draft input, hover. useState / useReducer colocated with the widget.
- 044. Global UI state — rare cross-tree UI: toast queue, auth shell, theme. Zustand/Context — thin on purpose.
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}Key idea
Common mistake
“All server data goes in Redux/Zustand.”
Architecture diagram (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
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 deferredProp contracts and composition
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 FiltersWhen global UI state is wrong
- 01Putting form drafts in a global store → ghost state after navigate; lose “discard” semantics.
- 02Mirroring server entities in Zustand “for speed” → dual writes, stale bugs.
- 03Filter state only in memory → cannot share link to a filtered view.
- 04Modal open flags in Redux for every dialog → ceremony without benefit; local or URL (?modal=) is enough.
- 05Form state in global store → type safety loss, harder a11y, no natural unmount cleanup.
Data fetching architecture
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})Common mistake
“Prop drilling means I need a global store.”
Component taxonomy that interviewers recognise
- 01Route/container — data + URL; thin.
- 02Feature composition — arranges sections; little logic.
- 03Presentational — props in, events out; Storybook-friendly.
- 04Headless/primitive — behaviour + a11y; style elsewhere.
- 05Utility hooks — reusable state machines, not dump for leftover logic.
Co-location vs splitting
Re-render discipline without cargo cult
Key idea
Mini design: notifications inbox state map
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)
- 01Prop drilling 5+ levels — re-renders + maintenance. Fix: composition or thin context for static shell props.
- 02Global store for everything — whole-app re-renders. Fix: co-locate; URL for shareable; server for remote.
- 03Mirrored server state — stale/inconsistent. Fix: TanStack Query / RSC; never hand-mirror.
- 04Form state in global store — ghost drafts. Fix: local reducer or useActionState.
- 05URL state only in component state — share/back broken. Fix: searchParams as source of truth.
Building resilient frontend architecture — Monica LentConference talkInterview answer bank — architecture
- 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.
- 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.
- 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.
- 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.
- 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.
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.Checkpoint
Product filters must be shareable via link and work with Back. Where does filter state live?
Checkpoint
When is a global client store the wrong choice?
Checkpoint
You profile a page: typing in a search box re-renders a 500-row table body every keystroke. Best first architecture fix?
Checkpoint
Prop drilling through three layout levels for a user avatar. Best response?
Checkpoint
Optimistic mark-as-read fails on the server. What must the client do?
Can you bucket any UI’s state into server cache / URL / local / global and defend the map?
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.