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:
- Primitives â low-level, unstyled, accessible building blocks (Button, Input, Dialog)
- Compositions â domain-agnostic combinations of primitives (SearchBar, DataTable, FormField)
- 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:
| Decision | Choice | Rationale |
|---|---|---|
| Component styling | CSS Modules | Zero runtime, SSR-friendly, team familiarity |
| State management | Zustand for global, local for UI | Minimal API surface, selector-based re-renders |
| Component API style | Compound components | Multiple products need different compositions |
| Data fetching | React Query | Handles cache, dedup, and background refresh |
Interview Signal
Architecture questions at the apex level test:
- Systems thinking â how components interact across a codebase, not just within a file
- Trade-off articulation â composition vs configuration, flexibility vs simplicity
- Team scalability â patterns that let multiple teams contribute without coordination overhead
- Abstraction judgment â knowing when to abstract and when premature abstraction is worse than duplication