Component patterns that scale
Variants over booleans, compound components, headless hooks, status as one value, and letting the caller pick the element.
Meer Habib
Senior Mobile Engineer · Chittagong
Any component is easy the first time. It gets hard around the tenth use, when someone needs it slightly bigger, slightly different, in a sheet instead of a page. These are the patterns that keep components simple at the tenth use, in React and React Native.
1. Variants, not booleans
Every boolean prop doubles the number of possible components. Three of them make eight, and some of those eight make no sense.
primary · outline · danger = 2³ = 8
3 of 8 make no sense, and all 8 compile.
variant × size = 3 × 2 = 6, all valid
impossible states can't be written.
Name the axes instead, and list the allowed values:
type ButtonProps = {
variant?: "solid" | "ghost" | "danger";
size?: "sm" | "md";
};
const styles = {
solid: "bg-ink text-on-ink",
ghost: "border border-line-strong",
danger: "bg-signal text-white",
};Now <Button variant="ghost" size="sm" /> is readable, and an outlined-primary-danger button can't be written.
2. Compound components
When a component has several parts, like a sheet with a trigger, content, a title and a close button, don't configure it with ten props. Let the parts be components that share state through context.
<Sheet>
<Sheet.Trigger>Open</Sheet.Trigger>
<Sheet.Content>
<Sheet.Title>Share</Sheet.Title>
<ShareOptions />
<Sheet.Close />
</Sheet.Content>
</Sheet>The caller decides the layout; the component guarantees the behaviour. This is how Radix and most good UI libraries are built, and it's the pattern I reach for most.
3. Logic in a hook, look in the component
Keep behaviour and appearance separate. The hook knows how something works; the component decides how it looks.
function useDisclosure(initial = false) {
const [open, setOpen] = useState(initial);
return { open, show: () => setOpen(true), hide: () => setOpen(false), toggle: () => setOpen((o) => !o) };
}The same hook can drive a sheet, a menu, a tooltip, an accordion. When the design changes, the logic doesn't; when the logic has a bug, it's fixed everywhere at once.
4. Controlled, uncontrolled, or both
A good input works either way. Pass value and onChange and the parent is in charge. Pass only defaultValue and the component looks after itself.
function Segmented({ value, defaultValue, onChange }: Props) {
const [inner, setInner] = useState(defaultValue);
const current = value ?? inner;
const select = (v: string) => {
if (value === undefined) setInner(v);
onChange?.(v);
};
// ...
}Simple uses stay simple; complex ones still have full control.
5. Status as one value
Four booleans for one screen (isLoading, isError, isEmpty, hasData) can be combined in sixteen ways, and most of them are bugs. A single union can only be one thing at a time:
type State =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "ready"; items: Item[] };The component must handle each case, and TypeScript tells you when you missed one. It pairs with designing all five screen states.
6. Let the caller choose the element
A button styled as a link, or a link styled as a button, is a classic mess. Let the caller pass the element:
<Button asChild>
<Link href="/hire">Start a project</Link>
</Button>The component supplies the styling and behaviour; the child supplies the semantics. The link stays a real link, so it works with the keyboard, middle-click and screen readers.
7. Few primitives, many screens
The goal of all of this is a small set of pieces that compose into everything: a pressable, text, a stack, a sheet, a chip, a segmented control. When a new screen needs a new primitive, that's a design conversation, not a quick one-off.
Rules for any component API
- Name props by intent:
variant="danger", notred. - Children over config. If you're passing an array of objects to render, consider parts instead.
- Pass through the rest. Forward
ref,classNameand accessibility props, so callers never have to fork your component. - Make impossible states impossible with types, not documentation.
Previous: design systems, from tokens to screens. The series ends with a real one: one press, one spring.
Building something like this?
Booking new projects for Q4. Replies within 24h.