Chapter 17

Component-Based Architecture

Composition, props, state ownership and design systems.

17. Component Architecture

Component architecture is the practice of dividing an interface into units that have clear responsibilities, stable interfaces, and predictable behavior. A component is not automatically reusable merely because it has a name or a file. Useful boundaries make change easier: a feature can evolve without forcing unrelated parts of the application to understand its internal details.

This chapter develops practical component boundaries, the single-responsibility principle, composition with children and compound components, render props, state placement, presentation/container roles, design tokens, headless primitives, and techniques for avoiding prop drilling without replacing it with unnecessary global state.

Overview & Learning Objectives

By the end of this chapter, you will be able to:

  1. Choose component boundaries based on responsibility, cohesion, and change.
  2. Compose components through children, compound APIs, and render props.
  3. Place state at the lowest common owner and lift it only when required.
  4. Separate presentation from data coordination when it improves clarity.
  5. Use design tokens and headless accessible primitives as architectural layers.
  6. Select context, composition, or a store instead of passing props through unrelated layers.
  7. Recognize over-abstraction and refactor component APIs toward useful constraints.

Component Boundaries and the Single-Responsibility Principle

A component should have a coherent reason to change. A CourseCard might render a course title, summary, image, and enrollment action because these pieces form one visual and behavioral concept. A large CoursePage that fetches data, parses query parameters, manages an unrelated modal, formats analytics, and renders every section has several reasons to change. Splitting it may help, but splitting every line into a component does not.

The single-responsibility principle (SRP) is often summarized as “one reason to change.” In UI architecture, interpret this as a boundary around a cohesive responsibility, not a rigid rule that each component can render only one element. A useful component:

  • owns a concept with a clear purpose;
  • has inputs whose meaning can be explained;
  • hides implementation choices that callers do not need;
  • has a reasonable testing surface;
  • can change without requiring widespread coordinated edits.

Start from the interface and the likely changes. If the design system changes button styling, a shared Button may localize that work. If a card's layout varies by only two contexts and both are simpler when written inline, a new configurable card abstraction may add more burden than it removes. Reuse is valuable when it consolidates a meaningful invariant, not when it merely eliminates repeated characters.

Cohesion, coupling, and stable interfaces

Cohesion is how closely a component's responsibilities belong together. Coupling is how much one part depends on the internal choices of another. A component that requires five unrelated boolean flags may have low cohesion and a caller-facing API that leaks implementation details:

jsx
<Panel
  isCard
  noPadding
  isCompact
  showDivider
  useAlternateHeader
/>

Some variants are legitimate, but combinations can become impossible to reason about. Prefer semantic variants or composition:

jsx
<Panel variant="compact">
  <Panel.Header>Upcoming lessons</Panel.Header>
  <Panel.Body>...</Panel.Body>
</Panel>

An interface should make valid usage easy and invalid states difficult. For example, a Dialog can own focus management and keyboard behavior rather than requiring every caller to coordinate open, backdrop clicks, Escape, and focus restoration independently.

Composition: Children, Compound Components, and Render Props

Composition is the primary way to build flexible UI without inventing a configuration option for every arrangement. In React-like APIs, children lets a parent component provide a slot:

jsx
function Notice({ tone = "info", title, children }) {
  return (
    <aside className={`notice notice--${tone}`} role="status">
      {title && <h2>{title}</h2>}
      <div>{children}</div>
    </aside>
  );
}

<Notice tone="success" title="Saved">
  Your changes are available to your team.
</Notice>

The component controls the stable outer structure while the caller supplies content. Use children when the component's composition is naturally nested. Explicit slots can clarify distinct regions:

jsx
function PageSection({ title, actions, children }) {
  return (
    <section className="page-section">
      <header className="page-section__header">
        <h2>{title}</h2>
        {actions}
      </header>
      <div className="page-section__content">{children}</div>
    </section>
  );
}

Slots should remain intentional. If a component accepts an arbitrary collection of props, elements, callbacks, and flags with no clear contract, it is not necessarily more flexible.

Compound components

Compound components present a group of related pieces under one conceptual API. A tab system may expose Tabs, Tabs.List, Tabs.Trigger, and Tabs.Panel. The parent coordinates shared selection state while children express structure:

jsx
<Tabs defaultValue="overview">
  <Tabs.List aria-label="Course information">
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="lessons">Lessons</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="overview">Course introduction</Tabs.Panel>
  <Tabs.Panel value="lessons">Lesson list</Tabs.Panel>
</Tabs>

Internally, context can share the current value and selection function among descendants. This is a good use of context because the children belong to the same composed widget. The parent should validate duplicate or missing values where practical, expose controlled and uncontrolled modes only if consumers need them, and preserve keyboard and ARIA behavior. A compound API can become opaque if parts work only in undocumented positions, so document which subcomponents belong together and how state is controlled.

Render props

A render prop is a function passed to a component that returns UI or receives state to render. It can be useful when a component owns behavior but the caller needs to control markup:

jsx
function PointerPosition({ children }) {
  const [position, setPosition] = React.useState({ x: 0, y: 0 });

  return (
    <div
      onPointerMove={(event) => setPosition({ x: event.clientX, y: event.clientY })}
      className="pointer-area"
    >
      {children(position)}
    </div>
  );
}

<PointerPosition>
  {({ x, y }) => <output>Pointer: {x}, {y}</output>}
</PointerPosition>

This pattern was historically used to share behavior before custom hooks became common. A hook is often simpler when the consumer controls the surrounding markup; render props remain useful when a component needs to own a subtree or provide a specialized stateful slot. Avoid calling a render function conditionally in a way that violates the consumer's hook rules: hooks belong in components or custom hooks, not arbitrary callbacks.

State Colocation and Lifting

State placement affects coupling. Keep state close to the components that use and update it, then move it upward only when multiple parts need to coordinate. This is state colocation: local state remains local, which limits rerenders and reduces the number of components that depend on a value.

Suppose one accordion item manages its open state and no other component needs to know it. The state belongs in that item. If the page must ensure only one item is open, the collection owner needs the active item ID and passes it down. This is called lifting state to the closest common owner:

jsx
function LessonList({ lessons }) {
  const [activeId, setActiveId] = React.useState(null);

  return lessons.map((lesson) => (
    <LessonRow
      key={lesson.id}
      lesson={lesson}
      open={activeId === lesson.id}
      onToggle={() => setActiveId(activeId === lesson.id ? null : lesson.id)}
    />
  ));
}

The owner is now the source of truth. Avoid storing derived values that can be calculated from existing state:

jsx
const completedCount = lessons.filter((lesson) => lesson.complete).length;

Keeping both lessons and a separately updated completedCount risks inconsistent state. Store a value when it represents an independent fact or when measurement proves that derivation is costly; otherwise derive it during rendering, and consider memoization only when needed.

Controlled and uncontrolled component APIs describe who owns state. A controlled input receives value and onChange; its caller is authoritative. An uncontrolled input manages its own value, often exposing an initial defaultValue. Do not switch an instance between controlled and uncontrolled modes during its lifetime. Decide whether both modes are valuable for the component's audience; supporting both increases implementation and test cases.

Presentational and Container Roles

The presentational/container distinction is a useful design lens, not a mandatory file structure. A presentational component primarily describes what to render from props. A container or feature component coordinates data, state, routing, and actions. The split can make UI logic testable and allow rendering in multiple contexts:

jsx
function CourseSummary({ course, onEnroll }) {
  return (
    <article>
      <h2>{course.title}</h2>
      <p>{course.description}</p>
      <button onClick={() => onEnroll(course.id)}>Enroll</button>
    </article>
  );
}

function FeaturedCourse({ courseId }) {
  const course = useCourse(courseId);
  const enroll = useEnroll();

  if (course.status === "loading") return <p>Loading course…</p>;
  if (course.status === "error") return <p role="alert">Could not load course.</p>;

  return <CourseSummary course={course.data} onEnroll={enroll} />;
}

This division is useful when data acquisition and presentation have different reuse or testing needs. It is not necessary to create a container for every component or to avoid hooks in visual components. A well-named feature component can use a custom hook and render its view if that is easier to understand. Avoid an abstraction where a “container” only forwards every prop without adding meaningful coordination.

Keep UI states explicit: loading, empty, error, and success states are distinct design cases. A component that handles all of them may be cohesive if it represents one feature. Its children can still be extracted for meaningful reuse or to reduce complexity. Use semantic markup and accessible status announcements where appropriate; an error message alone does not make a failed action recoverable.

Tokens and Visual Architecture

Design tokens are named values that communicate a visual decision: color roles, spacing steps, type sizes, radii, elevations, and motion. Tokens separate the design system's vocabulary from individual components. Prefer role-based names such as --color-text-muted or --space-control-gap over names tied only to a current palette like --blue-500, while retaining primitive scales where they are useful.

css
:root {
  --color-surface: #ffffff;
  --color-text: #172033;
  --color-text-muted: #526078;
  --color-border: #d5dce8;
  --color-action: #2457c5;
  --color-action-hover: #19449f;
  --radius-control: 0.5rem;
  --space-control-gap: 0.75rem;
}

[data-theme="dark"] {
  --color-surface: #151b28;
  --color-text: #f2f5fb;
  --color-text-muted: #bac5d7;
  --color-border: #3a465a;
  --color-action: #91b4ff;
  --color-action-hover: #bad0ff;
}

.button {
  color: white;
  background: var(--color-action);
  border-radius: var(--radius-control);
}

Component APIs should express semantic intent, for example variant="destructive", instead of allowing callers to invent arbitrary combinations of color and padding. Tokens make broad visual changes coherent, but avoid turning every CSS property into a global token. A token is useful when it names an intentional decision used consistently or expected to evolve centrally.

Headless Primitives and shadcn-Style Components

A headless primitive provides interaction behavior and accessibility semantics while leaving visual presentation to the consuming application. Radix-style primitives commonly expose accessible building blocks for dialogs, menus, popovers, and other widgets. They can manage difficult details such as focus trapping, keyboard navigation, dismissal, and ARIA relationships. These behaviors require careful implementation; replacing them with a styled div often recreates bugs.

A shadcn-style approach is different from installing a single opaque component library: selected component source is copied or generated into the application's codebase and styled with local tokens and utility classes, often while relying on accessible primitives underneath. This gives teams more direct ownership and customization, but also means they own updates, review, and maintenance of that code. Confirm the project's exact library and license terms, versions, and supported framework rather than assuming all similarly named projects behave identically.

Choose the abstraction layer deliberately:

  1. A primitive owns behavior and accessibility mechanics.
  2. A design-system component adds visual defaults and a stable product API.
  3. A feature composes those components to implement a task.

For example, a dialog primitive should coordinate open state and focus behavior; a design-system ConfirmDialog can establish button order, spacing, and typography; a feature supplies the confirmation message and action. Do not hide every primitive property that consumers might need, but avoid exposing implementation details as a permanent public API.

Avoiding Prop Drilling Without Globalizing Everything

Prop drilling describes passing a value through intermediate components that do not use it, solely so a distant descendant can receive it. A few levels of explicit props are often clear and beneficial: the data flow is visible, dependencies are easy to test, and refactoring is straightforward. The problem is not “more than two props”; it is incidental coupling through layers that have no reason to know the value.

Before introducing global state, consider:

  • Composition: Pass a fully configured child into a layout slot, so the intermediate component need not know its details.
  • Context: Share a value among a coherent subtree, such as the current theme, locale, or compound widget state.
  • A state store: Use when independent areas need coordinated access, updates, or subscriptions that do not map naturally to one component tree.
  • Route or server data: Keep data in the data layer when it is shared because of resource ownership, not merely because several components render it.

Context is not automatically a performance solution. Consumers rerender when the provided value changes according to the framework's context semantics. Split contexts by update frequency and concern; memoize a provider value only when identity stability has a measured purpose. A global store introduces its own subscription, debugging, and ownership questions. Use the smallest mechanism that makes dependencies understandable.

Refactoring and Review Practices

When a component becomes difficult to change, identify the pressure before extracting code. Is its render function too long? Does it coordinate unrelated data sources? Are multiple consumers duplicating a stable behavior? Is its API exposing too many combinations? Refactor around that problem, not around arbitrary line counts.

Designing a component API

Treat a reusable component's props as a small public API. Prefer names that describe the domain (selected, onSelect, size="small") over names that expose implementation (activeIndex, setInternalFlag). Use booleans for genuinely binary choices, but consider a named variant when multiple values communicate intent. An event callback should make its payload clear: onSelect(courseId) is easier to use than a callback whose argument changes shape based on a mode flag.

Forwarding arbitrary DOM props can be useful for low-level primitives, but it should not silently override important attributes or event handling. Decide which attributes are owned by the component and which are intentionally passed through. A button wrapper, for example, should preserve native button semantics and expose disabled rather than simulating it with a class. TypeScript discriminated unions can enforce mutually exclusive options when a component has alternate modes:

ts
type LinkActionProps =
  | { href: string; onClick?: never }
  | { href?: never; onClick: () => void };

This encodes that a navigation action is either a link or a button-like action, instead of accepting a confusing mixture. The HTML element should match the behavior: use an anchor to navigate and a button to perform an action.

Avoid callback props for actions that can be expressed more simply through composition. A dialog does not need a renderFooter callback if callers can pass a footer slot. Conversely, do not force a slot API when the component needs a single semantic action. Each API shape adds a mental and testing cost, so choose the smallest one that supports the real variations.

Extracting shared behavior

Two components that look alike may not share a stable responsibility. Before extracting a shared component, compare their behavior, accessibility needs, and likely evolution. If one card is a navigation link and the other is a selectable control, forcing both into a generic Card with many flags can obscure semantics. A lower-level visual primitive can share border and spacing while separate feature components retain the correct element and interaction.

Shared logic may be extracted without sharing the markup. A custom hook can manage keyboard navigation while two consumers render different structures. A helper can format a value without creating a component. Conversely, a repeated visual pattern with accessibility behavior may deserve a component even if only two current screens use it. Reuse is a design judgment; count of call sites alone does not decide it.

Useful review questions include:

  1. Can a caller explain the component's purpose from its name and required props?
  2. Are state and side effects owned by the part that has the domain context to manage them?
  3. Are repeated pieces genuinely invariant, or merely similar today?
  4. Are loading, empty, error, and disabled states handled?
  5. Does the component preserve keyboard interaction, labels, focus, and status semantics?
  6. Can a change to one feature be made without editing unrelated components?
  7. Does the component's public API reflect user-facing behavior instead of private implementation details?

Keep tests focused on public behavior: accessible names, interactions, and rendered states. Avoid tests that lock down private component structure or every CSS class. Storybook or another component workbench can help review variants and edge cases when the project uses one, but it is not a substitute for integration tests of workflows.

Exercises

Exercise 1: Refactor a crowded course page

A CoursePage fetches a course, stores the selected tab, controls a share dialog, renders a lesson list, and formats a progress percentage. Propose boundaries and state ownership. Show a compact component outline that avoids creating a separate component for every DOM node.

Solution

The route-level CoursePage can own or call a data hook for the course and coordinate loading/error states. A CourseTabs compound widget can own selected-tab behavior if tabs are reusable and need keyboard support; if only one simple switch exists, a small local state in the route may be enough. ShareDialog should own dialog interaction details or compose an accessible dialog primitive, while the page controls whether it is open because the share action belongs to the page workflow. A LessonList is a meaningful boundary if it handles lesson navigation, empty state, or list-specific behavior. ProgressSummary can display course and derived percentage without storing a duplicate percentage.

jsx
function CoursePage({ courseId }) {
  const course = useCourse(courseId);
  const [shareOpen, setShareOpen] = React.useState(false);

  if (course.status === "loading") return <CoursePageSkeleton />;
  if (course.status === "error") return <CourseLoadError onRetry={course.retry} />;

  const completed = course.data.lessons.filter((lesson) => lesson.complete).length;
  const progress = course.data.lessons.length
    ? Math.round((completed / course.data.lessons.length) * 100)
    : 0;

  return (
    <main>
      <CourseHeader course={course.data} onShare={() => setShareOpen(true)} />
      <ProgressSummary value={progress} />
      <CourseTabs defaultValue="overview">
        <CourseTabs.List aria-label="Course sections">
          <CourseTabs.Trigger value="overview">Overview</CourseTabs.Trigger>
          <CourseTabs.Trigger value="lessons">Lessons</CourseTabs.Trigger>
        </CourseTabs.List>
        <CourseTabs.Panel value="overview">{course.data.description}</CourseTabs.Panel>
        <CourseTabs.Panel value="lessons"><LessonList lessons={course.data.lessons} /></CourseTabs.Panel>
      </CourseTabs>
      <ShareDialog open={shareOpen} onOpenChange={setShareOpen} course={course.data} />
    </main>
  );
}

The exact split depends on reuse and complexity. The important choices are that the route owns feature coordination, the dialog owns accessible dialog mechanics, and progress is derived instead of duplicated.

Exercise 2: Stop drilling theme and dialog props

A Dashboard passes theme, setTheme, and dialogOpen through Header, Navigation, and NavGroup to ThemeButton and HelpButton. Propose a composition/context design. State which values belong in context and which should remain local.

Solution

Theme is a cross-cutting preference used across the interface, so a ThemeProvider context is a reasonable owner. It can expose a stable value and setter, ideally backed by persisted application preference while respecting a system default. The help dialog's open state may belong in HelpButton if no other part of the dashboard controls it; the button can compose a HelpDialog. If the dashboard must open help from multiple entry points, lift that state to a shared HelpProvider or pass a callback through a meaningful feature boundary. Do not place every dialog in one global context by default.

jsx
const ThemeContext = React.createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState("system");
  const value = React.useMemo(() => ({ theme, setTheme }), [theme]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

function ThemeButton() {
  const themeContext = React.useContext(ThemeContext);
  if (!themeContext) throw new Error("ThemeButton must be inside ThemeProvider");

  const { theme, setTheme } = themeContext;
  const nextTheme = theme === "dark" ? "light" : "dark";

  return (
    <button onClick={() => setTheme(nextTheme)}>
      Switch to {nextTheme} theme
    </button>
  );
}

function HelpButton() {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Help</button>
      <HelpDialog open={open} onOpenChange={setOpen} />
    </>
  );
}

Wrap the app or relevant subtree in ThemeProvider; the intermediate navigation components no longer receive unrelated props. If theme changes trigger excessive work, inspect actual consumers and split contexts or adjust provider placement. Keep HelpDialog local until there is a concrete need to control it elsewhere.