modern-react-spa

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:

  1. App root — catches everything that escapes inner boundaries. Always include.
  2. Route level — one error boundary per route; a broken route doesn’t crash the shell.
  3. 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 throw after await; or React 19’s use(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

  1. Container/Presentational split in new code — outdated; refactor when you touch it.
  2. Twenty props on a “configurable” component — slots are the answer.
  3. Compound components without context — defeats the pattern; use Radix as the reference.
  4. One giant <Suspense> at the root — every loading screen looks the same.
  5. No error boundary at the route level — one broken route crashes the whole shell.
  6. Error boundary inside Suspense — events fire in the wrong order; outer error boundary works correctly.
  7. 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

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.

Topics in this chapter (5)
  1. 4.1 Container / Presentational is dead
  2. 4.2 Composition over configuration — slots and compound components
  3. 4.3 Error boundaries with react-error-boundary
  4. 4.4 Suspense boundary placement
  5. 4.5 The dashboard-shell mock