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:
<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:
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.