DNA🏗ïļ System DesignComponent Architecture
👑ApexArchitectureSystem DesignReactDesign Systems

Component Architecture

At the apex level, component architecture isn't about individual components — it's about the system of constraints that makes a codebase scale across teams.

Component Architecture

Component architecture at scale is not a React problem — it's an organizational problem. The patterns here determine whether 50 engineers can ship independently or whether every PR creates merge conflicts.

The Three Layers

Scalable component architectures separate concerns into three layers:

  1. Primitives — low-level, unstyled, accessible building blocks (Button, Input, Dialog)
  2. Compositions — domain-agnostic combinations of primitives (SearchBar, DataTable, FormField)
  3. Features — domain-specific components that contain business logic (CheckoutForm, UserProfile)
Feature Components (business logic, state, API calls)
        ↓ uses
Composition Components (layout, coordination)
        ↓ uses
Primitive Components (accessibility, styling, interaction)

Each layer has different rules: primitives never import from compositions or features. Features never export to other features — shared logic moves down to compositions.

Compound Components at Scale

In a design system serving multiple products, compound components provide flexibility without props explosion:

const DataTable = ({ children, data, columns }: DataTableProps) => {
  const [sort, setSort] = useState<SortConfig | null>(null);
  const [selected, setSelected] = useState<Set<string>>(new Set());
  
  const ctx = useMemo(
    () => ({ data, columns, sort, setSort, selected, setSelected }),
    [data, columns, sort, selected]
  );
 
  return (
    <DataTableContext.Provider value={ctx}>
      <div role="table">{children}</div>
    </DataTableContext.Provider>
  );
};
 
DataTable.Header = DataTableHeader;
DataTable.Body = DataTableBody;
DataTable.Row = DataTableRow;
DataTable.Cell = DataTableCell;
DataTable.Pagination = DataTablePagination;
DataTable.SelectAll = DataTableSelectAll;
 
// Consumer decides composition
<DataTable data={users} columns={columns}>
  <DataTable.SelectAll />
  <DataTable.Header />
  <DataTable.Body>
    {(row) => (
      <DataTable.Row key={row.id}>
        <DataTable.Cell>{row.name}</DataTable.Cell>
        <DataTable.Cell>{row.email}</DataTable.Cell>
      </DataTable.Row>
    )}
  </DataTable.Body>
  <DataTable.Pagination />
</DataTable>

Composition Over Configuration

The anti-pattern: prop-driven everything:

// Prop explosion — each product needs different config
<DataTable
  data={users}
  sortable
  selectable
  paginated
  filterPosition="top"
  emptyState="No users found"
  onRowClick={handleClick}
  rowClassName={(row) => row.active ? 'active' : ''}
  // ...50 more props
/>

The alternative: composition, where consumers assemble what they need:

<DataTable data={users}>
  <Toolbar>
    <Filter />
    <BulkActions />
  </Toolbar>
  <DataTable.Header sortable />
  <DataTable.Body emptyState={<EmptyUsers />} />
  <DataTable.Pagination />
</DataTable>

Composition shifts complexity from the library author to the consumer — but it's manageable complexity because each piece is independently understandable.

Dependency Injection via Props and Context

Hard-coded dependencies make components untestable and inflexible:

// Tightly coupled: imports API client directly
function UserList() {
  const { data } = useSWR('/api/users', fetcher);
  return <List items={data} />;
}
 
// Dependency injection: behavior is provided, not assumed
function UserList({ useUsers }: { useUsers: () => { data: User[] } }) {
  const { data } = useUsers();
  return <List items={data} />;
}

At the architecture level, dependency injection via context lets you swap implementations across an entire subtree:

const APIContext = createContext<APIClient>(productionClient);
 
// In tests
<APIContext.Provider value={mockClient}>
  <App />
</APIContext.Provider>
 
// In Storybook
<APIContext.Provider value={fixtureClient}>
  <UserList />
</APIContext.Provider>

Inversion of Control

The most powerful architectural pattern: let consumers control behavior without modifying library code.

Headless components take this to the extreme — they provide logic and state, consumers provide all rendering:

function useCombobox<T>({ items, onSelect }: ComboboxConfig<T>) {
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const filtered = items.filter(/* ... */);
 
  return {
    inputProps: {
      value: query,
      onChange: (e) => setQuery(e.target.value),
      onKeyDown: handleKeyDown,
      role: 'combobox',
      'aria-expanded': open,
    },
    listProps: { role: 'listbox' },
    getItemProps: (index: number) => ({
      role: 'option',
      'aria-selected': index === activeIndex,
      onClick: () => onSelect(filtered[index]),
    }),
    filtered,
    open,
  };
}

Libraries like Radix, Headless UI, and Downshift use this pattern. It's the ultimate expression of separation between logic and presentation.

Architecture Decision Records

At apex level, document why you chose a pattern, not just what you chose:

DecisionChoiceRationale
Component stylingCSS ModulesZero runtime, SSR-friendly, team familiarity
State managementZustand for global, local for UIMinimal API surface, selector-based re-renders
Component API styleCompound componentsMultiple products need different compositions
Data fetchingReact QueryHandles cache, dedup, and background refresh

Interview Signal

Architecture questions at the apex level test:

  1. Systems thinking — how components interact across a codebase, not just within a file
  2. Trade-off articulation — composition vs configuration, flexibility vs simplicity
  3. Team scalability — patterns that let multiple teams contribute without coordination overhead
  4. Abstraction judgment — knowing when to abstract and when premature abstraction is worse than duplication