fn42dev
← Back to blog

Composition over coupling: leave the hole empty

If I had to point at the single decision that kept Genesys UI maintainable as it grew, it wouldn't be a component. It would be a habit: components compose, they don't couple. And the difference is almost always one small choice made over and over.

The easy mistake

You're building a Widget, and the design says it has an icon. The obvious move is to give it an icon:

coupling — the Widget reaches into the icon library
<Widget iconName="search" />

It feels clean. It reads well. And you have just welded Widget to the icon library forever. That iconName string is really an anchor to the icon's whole definition: the day the icon gains a color prop, Widget needs an iconColor to forward it, then an iconSize, an iconBadge, and on it goes — re-exposing the icon's entire API one prop at a time. If you know the exact final use you can deliberately cap it there; if you don't, every component that touches an icon ends up mapping all of its props. Repeat that across a dozen components and it simply doesn't scale. Widget doesn't have an icon — it has a permanent obligation to one specific library, and the obligation multiplies.

Leave the hole empty

The alternative is to give the Widget a space and let the caller fill it with whatever they want:

composition — the Widget just leaves a hole
type WidgetProps = {
  // Not a dependency. A hole.
  icon?: React.ReactNode;
};
 
<Widget icon={<IconSearch />} />

Widget now knows nothing about icons. It reserves a slot and renders whatever you hand it — which means the slot quietly accepts everything:

<Widget icon={<Avatar src={user.photo} />} />
<Widget icon={<StatusDot tone="warning" />} />
<Widget icon={<span>🔍</span>} />

The icon library and the Widget no longer know each other exist. They meet only at the call site, on the user's terms. That's the whole trick: push the decision outward, to the one place that actually has the context to make it.

When the hole has to talk back

Composition isn't free of tension. Give Widget a status="error" and two things should happen: the widget restyles itself, and its icon turns red. The first is easy — the widget owns its own chrome. The second is awkward: the icon came from the caller, and a hole is by definition something Widget doesn't control. Push the colour back onto the caller and the pattern leaks — now they have to remember to paint the icon red whenever the status is an error.

There are several ways out; two are worth contrasting.

The blunt one: if status="error" always implies an error icon, bake it into a dedicated WidgetError that fills the hole itself. Honest and simple, at the cost of one more component and a little less freedom.

The one I prefer, because it keeps the freedom intact, is to agree on a contract through React context. The widget wraps its slot in an <IconContextProvider color="danger">, and every icon in our ecosystem knows to read that context and colour itself accordingly. The caller still passes whatever icon they like; the container shapes it without owning it. A foreign icon — one from another library — won't speak the contract, so it just ignores the colour; when that matters, you wrap that library once to honour the context, or style the rendered icon directly. The hole stays a hole, but now it can whisper to whatever fills it.

Examples are how the practice spreads

There's a catch. If you leave a hole, you also leave room to misuse it — so the freedom has to come with guidance. That guidance isn't a lint rule or a paragraph in the docs. It's the examples.

Nobody reads an API top to bottom. People find the example closest to their case, copy it, and bend it until it fits. So the example is the documentation, and the shape you give it is the shape most people will ship. If the example already wires the icon in by composition, composition is what gets copied — the good practice propagates by imitation, which is the only way it ever really propagates.

The corollary is to leave no gap for improvisation in the easy cases. Give people a button with an add-on on the right, and another with one on the left, and almost nobody hand-builds their own — they copy the closest example and move on. Omit those, and the first person who wants an add-on starts stacking an icon, then wedges other components around it to line things up, and now there's a bespoke little contraption in the codebase that you'll meet again in review. Enough examples, covering the obvious cases, are the cheapest guardrail you can build.

And in a tool like Storybook there's a bonus: those same examples are the tests the component library needs anyway. One artefact, two problems solved — the canonical example teaches the pattern and guards against regressions at once.

Coupling is inheritance in disguise

Here's why this matters more than it looks. Coupling two libraries together is the same bet as reaching for inheritance: it's wonderfully convenient at small scale, and it compounds into something miserable the moment the system grows.

Each coupled edge behaves like an inherited method: trivial to add, and quietly load-bearing forever after. Widget accretes special cases — this icon must be a button, that one an avatar — until it's a tangle of threads tying it to decisions that were never its to make. Every one of those edges is a reason two parts can't move independently, and a design system is nothing but parts that need to move independently.

Composition is the opposite trade. A bit more ceremony up front — you pass a node instead of a string — in exchange for components that never need to know about each other. The workload doesn't pile up, because each component owns less. The hole stays a hole, and the system stays a system instead of a knot.

This is the component-level version of the rule from Monorepo or many repos?: you build an ecosystem by designing for composition, not by wiring parts together. Down here it's a ReactNode prop; up there it's a package boundary. Same instinct, all the way up — leave the hole empty, and let people fill it.