Thinking in components
The component is React's unit of reuse, and it's where most apps accumulate debt. Not because people write bad components — because they write one component where there were three.
This page is about finding the joints.
Two species, and don't mix them
| Species | Knows what | Receives | Tested how |
|---|---|---|---|
| Presentational | how something looks | props | render + props |
| Domain | what an "order" is | props + feature hooks | render + mocked hook |
The presentational component doesn't know where the data comes from. The domain one knows the domain, but doesn't know HTTP (that's the service).
// Presentational: reusable in any app, any domain.
export function StatusBadge({ tone, children }: StatusBadgeProps) {
return <Badge variant={tone}>{children}</Badge>;
}
// Domain: translates "order" into "appearance".
export function OrderStatusBadge({ status }: { status: OrderStatus }) {
return <StatusBadge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</StatusBadge>;
}
The presentational one is a candidate to move up into the SDK. The domain one never moves up — it belongs to your app, and that's correct.
Before writing a presentational component, search the SDK
There are 117 components. Badge, DataTable, Modal, Combobox, Stepper,
EmptyState, Skeleton, Toast… See the
catalogue. Rewriting Modal with a correct focus trap is a
week you don't need to spend.
When to split a component
Don't split by size — split by reason to change. The four signals:
1. The name has "and" in it
UserCardAndActions, TableWithFilters, FormAndPreview. The name is telling you
there are two.
2. One piece changes for a different reason
If the screen's <header> changes when design changes and the <table> changes
when the API changes, that's two responsibilities in one file.
3. A piece repeats
Twice: maybe coincidence. Three times: extract.
4. You need a big if in the JSX
// ❌ three screens in one component
{isLoading ? <Spinner /> : error ? <ErrorState … /> : orders.length === 0 ? <EmptyState … /> : <table>…</table>}
That asks for a state component or a switch in a sub-component.
Splitting too early also costs
A 6-line component used in one place, with 5 props, is indirection with no payoff. The question isn't "can this be split?" — it's "does this piece have a life of its own?".
Props: design the interface, not the data hand-off
Props are the component's public API. The rules that save the most pain:
At most 7 props — and count honestly
Going past 7 is the most reliable signal that there are two components in there. The way out isn't "group them into an object" (that only hides it), it's splitting.
Boolean props don't scale
<Button primary secondary danger small large />
Eight representable invalid combinations. What happens with primary danger?
<Button variant="danger" size="sm" />
A string union: the compiler only accepts what exists. It's how every SDK component is designed.
Rule: three or more mutually exclusive booleans → make it a union.
Pass children, not content
// ✅ composition: the caller decides what goes inside
<Card>
<OrderSummary order={order} />
</Card>
// ❌ configuration: Card must know every case
<Card contentType="order-summary" order={order} />
Composition is what prevents the component that grows one prop per use case.
Slots when you need more than one spot
interface PageProps {
title: ReactNode;
actions?: ReactNode; // slot: a button, a menu, whatever the caller wants
toolbar?: ReactNode;
children: ReactNode;
}
ReactNode in a slot beats string: the caller can pass text, an icon, or a whole
component, without Page changing.
Never ...props untyped
// ✅ extends the native element — inherits aria-*, data-*, onClick, className
interface CardProps extends HTMLAttributes<HTMLDivElement> {
elevated?: boolean;
}
export function Card({ elevated, className, ...rest }: CardProps) {
return <div className={cn(styles.card, elevated && styles.elevated, className)} {...rest} />;
}
Extending HTMLAttributes<T> gives you accessibility and composition for free,
with autocomplete. props: any gives you nothing.
cn is the utility for this
The SDK's cn(...) joins classes ignoring false/undefined, and the caller's
className comes last — so consumers can override. See
Utilities.
Extract logic into a hook, not another component
When the component is big because of logic, the cut isn't vertical (two components) — it's horizontal (component + hook):
export function OrderTable({ orders }: OrderTableProps) {
const [sort, setSort] = useState<Sort>({ field: "code", order: "asc" });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const toggle = (id: string) => { /* 12 lines */ };
const sorted = /* 20 lines */;
const paged = /* 8 lines */;
return <table>{/* 110 lines of JSX */}</table>;
}
// use-order-table.ts — the logic, testable without a DOM
export function useOrderTable(orders: Order[]) {
const [sort, setSort] = useState<Sort>({ field: "code", order: "asc" });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const rows = useMemo(() => paginate(sortBy(orders, sort), page), [orders, sort, page]);
return { rows, sort, setSort, selected, toggle, page, setPage };
}
// OrderTable.tsx — markup only
export function OrderTable({ orders }: OrderTableProps) {
const { rows, sort, setSort, selected, toggle } = useOrderTable(orders);
return <table>{/* lean JSX */}</table>;
}
The hook tests with renderHook — no DOM, no screen.getByRole, fast. The
component tests what actually matters in it: what appears on screen.
The custom hook is the frontend's 'service'
It's where screen logic lives. But it has a limit too: a 250-line
hook is a service in disguise — split it into smaller hooks or move the pure part
into a function in lib/.
React.memo, useMemo, useCallback: by measurement
React re-renders fast. memo everywhere costs a prop comparison on every render and
complicates the code with cascading useCallback.
Use it when:
- The component renders large lists (hundreds of items).
- The profiler shows an expensive render — not "looks expensive".
- The prop is an object/array recreated every render and the child is
memo.
Before optimizing renders, look at the design
A 5,000-item list doesn't need memo — it needs
VirtualList/VirtualTable. The win from virtualizing
is orders of magnitude bigger than any memoization.
Accessibility is part of the component, not polish
An interactive component without these is incomplete:
- The right element:
<button>for an action,<a>for navigation.<div onClick>can't be focused and doesn't respond to Enter. - A
labelassociated with every field (<Label htmlFor>oraria-label). - Visible focus — never
outline: nonewithout a replacement. - Overlays (modal, drawer, popover): focus trapped inside,
Esccloses, focus returns to the trigger on exit.
The SDK components already implement this; what you write on top is what needs
attention. CI runs axe against the gallery — see
Testing strategy.
Recap
- Two species: presentational (no domain) and domain (no HTTP).
- Split by reason to change, not by line count: a name with "and", a repeated
piece, a big
ifin the JSX. - ≤ 7 props; mutually exclusive booleans become a
union;childrenand slots before configuration. ...resttyped viaHTMLAttributes<T>— accessibility and composition for free.- A component that's big because of logic → extract a hook, not another component.
- Memoize by measurement; a large list wants virtualization, not
memo.
Next: Hard limits — the numbers.