Skip to content
1 of 3 project slots open
Writing
Design · part 4 of 55 min read

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

Btn
Btn
Btn
Btn
Btn
Btn
Btn
Btn

3 of 8 make no sense, and all 8 compile.

variant × size = 3 × 2 = 6, all valid

solid
ghost
danger
solid
ghost
danger

impossible states can't be written.

Fig. 1Three booleans allow eight buttons, three of them nonsense. Two named axes allow only real ones.

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.Root · owns open / closeOpen×Sheet.Triggeropens itSheet.Contentthe panelSheet.Titlenames it for screen readersSheet.Closecloses it
Fig. 2A compound component, pulled apart. The root owns the state; each part is placed wherever the caller wants.
<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", not red.
  • Children over config. If you're passing an array of objects to render, consider parts instead.
  • Pass through the rest. Forward ref, className and 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.