Chapter 04
Component Architecture for SPAs
Component architecture for modern React SPAs — what replaced Container / Presentational, compound-component slots, error boundaries, and where to place Suspense in a real dashboard tree.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can architect a React 19.2 SPA component tree using current patterns (not Container/Presentational), place Suspense and error boundaries deliberately, and recognise the compound-component / slot patterns that have replaced the old prop-drilling messes.
🧭 Prerequisites — Ch 2 (Suspense, error boundaries, refs as props), Ch 3 (hooks).
🔹 4.1 Container / Presentational is dead
The pattern (Dan Abramov, 2015): split components into container (fetches data, has state) and presentational (renders, no state). It was a coping mechanism for class components without hooks.
Hooks (Ch 1 §1.2) made the split obsolete. A component can fetch data via useQuery and render it in the same file without losing testability or reusability. The mental model that replaced it:
Modern decomposition
Split by concern, not by “smart vs dumb”:
- Route-level components — own the route’s data, layout, and routing-aware logic.
- Feature components — own a coherent piece of UI behaviour (an invoice editor, a tenant switcher).
- Primitive components — small, reusable, no data. The Ch 11 design system.
<InvoiceRoute> ← route-level: loader, page-level state
<AppShell> ← feature: layout
<Sidebar /> ← feature
<main>
<InvoiceEditor id={id}> ← feature: this invoice's editing flow
<Button>Save</Button> ← primitive (Ch 11)
</InvoiceEditor>
</main>
</AppShell>
</InvoiceRoute>
No “container” components, no connect() HOC, no nominal split. The hierarchy follows the product, not a 2015 architectural taxonomy.
🔹 4.2 Composition over configuration — slots and compound components
The mid-2010s tried to express variation through props:
// ❌ configuration explosion
<Card
title="…"
showHeader
showFooter
footerActions={[{ label: 'Save', onClick: save }, { label: 'Cancel', onClick: cancel }]}
bodyPadding="large"
variant="elevated"
/>
A dozen props; every new variant adds another; nothing is rearrangeable. The modern pattern: slot-based composition.
Slots
<Card
header={<CardHeader title="Invoice" />}
footer={
<CardFooter>
<Button variant="ghost" onClick={cancel}>Cancel</Button>
<Button onClick={save}>Save</Button>
</CardFooter>
}
>
<InvoiceForm />
</Card>
The card’s header, footer, and children are slots. The consumer puts arbitrary JSX in each. Variants emerge from composition; the Card API stays small.
Compound components
Components that share state via React Context, with a public sub-component API:
<Accordion type="single" defaultValue="invoice">
<Accordion.Item value="invoice">
<Accordion.Trigger>Invoice</Accordion.Trigger>
<Accordion.Content>…</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="line-items">
<Accordion.Trigger>Line items</Accordion.Trigger>
<Accordion.Content>…</Accordion.Content>
</Accordion.Item>
</Accordion>
The parent (Accordion) holds state. The children (Accordion.Trigger, Accordion.Content) read it via context. The consumer composes the structure; the implementation enforces accessibility (keyboard, focus, ARIA).
Radix UI (Ch 7.3) is built on this pattern. shadcn’s components inherit it.
The asChild / Slot polymorphism pattern
<Button asChild>
<a href="/invoices/1042">View invoice</a>
</Button>
Button styles its child as a button but renders the child’s element. No as={...} prop; the child is the element. Uses Radix’s <Slot> primitive under the hood.
🔹 4.3 Error boundaries with react-error-boundary
React’s built-in error boundaries are class components only (one of the few class-only APIs in 19.2). The community library wraps it ergonomically:
npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';
const Fallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<div role="alert">
<p>Something went wrong.</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
<ErrorBoundary FallbackComponent={Fallback} onReset={() => /* refetch */}>
<Dashboard />
</ErrorBoundary>
Three levels of placement:
- App root — catches everything that escapes inner boundaries. Always include.
- Route level — one error boundary per route; a broken route doesn’t crash the shell.
- Feature level — around the biggest feature components, so a broken feature shows an in-place fallback while the rest of the page continues to work.
What error boundaries catch and don’t
- ✅ Render errors in child components.
- ✅ Errors in lifecycle methods.
- ❌ Errors in event handlers (try/catch in the handler).
- ❌ Async errors (need explicit
throwafter await; or React 19’suse(promise)pattern surfaces them). - ❌ Errors in the error boundary itself.
🔹 4.4 Suspense boundary placement
Suspense lets you declaratively show a fallback while children “suspend” (waiting on data or a lazy component).
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
Placement strategy:
- One at the route boundary for the lazy-loaded route bundle. Standard.
- Per-section for the dashboard’s slow widgets — one Suspense around the analytics chart, another around the recent-activity feed. Each suspends independently; one slow widget doesn’t block the others.
- Per-row for paginated lists where individual rows fetch their own data. Rare; expensive.
<AppShell>
<main>
<Suspense fallback={<HeaderSkeleton />}> {/* fast */}
<Header />
</Suspense>
<Suspense fallback={<ChartSkeleton />}> {/* slow */}
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}> {/* medium */}
<InvoiceTable />
</Suspense>
</main>
</AppShell>
Three independent boundaries → three independent loading states. The Header appears first; Chart and Table fill in as their data arrives.
⚠️ Don’t wrap every component in Suspense. The fallback choreography becomes the user experience. Boundaries should align with the user’s mental model of “what’s loading.”
Suspense + Error boundaries
The pair:
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<Loading />}>
<Feature />
</Suspense>
</ErrorBoundary>
While loading → show Loading. On success → render Feature. On error → show ErrorFallback. The two together replace the old { isLoading ? … : error ? … : data ? … : null } ternary mess.
🔹 4.5 The dashboard-shell mock
A real-world layout combining everything in this chapter:
┌──────────────────────────────────────────────────────────────────┐
│ 🏢 Acme Admin [ 🔔 3 ] [ 👤 shreya ▾ ] │
├──────────┬──────────────────────────────────────────────────────┤
│ Home │ Invoices │
│ Invoices │ ┌─ Header ─────────────────────────────────────────┐│
│ Tenants │ │ Filters: ▾ Last 30d ▾ All tenants [ ⏵ Export ]││
│ Settings │ └─────────────────────────────────────────────────┘│
│ │ ┌─ Suspense: ChartSkeleton ──────────────────────┐│
│ │ │ (revenue chart, slow) ││
│ │ └─────────────────────────────────────────────────┘│
│ │ ┌─ Suspense: TableSkeleton ─────────────────────┐ │
│ │ │ ErrorBoundary: TableErrorFallback │ │
│ │ │ InvoiceTable (data + interactions) │ │
│ │ └─────────────────────────────────────────────────┘│
└──────────┴──────────────────────────────────────────────────────┘
▲ ▲
<Sidebar> — owned by <AppShell> slot pattern:
<DashboardRoute>
header={…}
chart={…}
table={…}
/>
The shell owns the layout; route components fill the slots; each filled slot has its own Suspense; the table has its own error boundary.
const InvoicesRoute = () => (
<DashboardLayout
header={<InvoicesHeader />}
chart={
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
}
table={
<ErrorBoundary FallbackComponent={TableError}>
<Suspense fallback={<TableSkeleton />}>
<InvoiceTable />
</Suspense>
</ErrorBoundary>
}
/>
);
This shape scales. Add a new widget → new slot, new Suspense. The shell doesn’t change.
🪤 Common Pitfalls
- Container/Presentational split in new code — outdated; refactor when you touch it.
- Twenty props on a “configurable” component — slots are the answer.
- Compound components without context — defeats the pattern; use Radix as the reference.
- One giant
<Suspense>at the root — every loading screen looks the same. - No error boundary at the route level — one broken route crashes the whole shell.
- Error boundary inside Suspense — events fire in the wrong order; outer error boundary works correctly.
- Catching errors in event handlers via error boundary — they don’t propagate; use try/catch.
✅ Recap
- Decompose by concern (route / feature / primitive), not Container/Presentational.
- Slots and compound components for composition; the consumer assembles.
- Error boundaries at app + route + (selectively) feature.
- Suspense boundaries align with the user’s “what’s loading” mental model.
- The pair
<ErrorBoundary><Suspense>...</Suspense></ErrorBoundary>replaces the old loading-state ternary.
🔗 Further Reading
- https://github.com/bvaughn/react-error-boundary —
react-error-boundary(Brian Vaughn). - https://www.radix-ui.com/primitives — Radix Primitives (compound-component reference).
- https://react.dev/reference/react/Suspense — Suspense (react.dev reference, React 19.2).
- Ch 7 (shadcn — slot patterns in practice), Ch 11 (component library), Ch 14 (TanStack Query — pairs with Suspense for data).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.