Saying no is most of the design system job
TL;DR
- A design system that a dozen apps use is a public API. Deleting four props from a component that had never shipped cost nothing. After a release, every flag needs a deprecated alias and a full deprecation cycle. So most of the job is saying no in review, before merge.
- Below are eight rules I enforce in review, each with the case behind it. Among them: compose sub-components instead of piling up boolean props, read a component's code before you build on it, and fix a problem in the layer that causes it.
- Some of my own arguments were weaker than they sounded. "Four booleans is sixteen combinations to test" is mostly rhetoric. A reviewer was right about two costs of the composition I recommend. And I'd now push back on my own old slogan, "when a gate has a blind spot, move the code, not the gate".
- November 2026 update: a change that destructured a prop declared on the wrong props type passed lint, tests and build, and I had been reading those three green checks as "it compiles". A gate (a check a change has to pass before it merges) you have never seen fail is not a gate.
Why a change gets expensive once the library ships
A design system consumed by a dozen apps is not a component folder. It is a public API you can't refactor unilaterally, owned by a team that isn't the one paying for your mistakes.
The turning point is the first release, when apps can start depending on what you published. After it, you can't simply delete a prop. It needs a deprecated alias (the old name keeps working, marked as deprecated) and a full deprecation cycle (time for every app to move off it before it goes), because the apps using it can't be forced to migrate. Rules 1 and 6 put a price on it:
| The same change | Before the first release | After it |
|---|---|---|
| Delete four boolean props (rule 1) | Cost nothing: the component had never shipped | A deprecated alias for every flag and a full deprecation cycle |
| Settle compound parts vs flags (rule 1) | A five-minute conversation | A quarter of migration |
| Delete a working component whose props type is exported (rule 6) | One rm |
A deprecation cycle |
Everything below is a rule I actually enforce in review, with the case that produced it. None of it is about visual design.
Shaping a component's API (rules 1 to 3)
Compound components vs boolean props, in two minutes
The first three rules are about components made of parts. There are two ways to let a consumer switch a component's blocks on and off:
- Boolean props (flags). One component with a prop per block, such as
showHintorshowActions. The component decides the order of the blocks. - Compound components (the compound pattern). The root component (
<Panel>) takes the value and the callbacks. Each block is a part attached to the root (Panel.Body,Panel.Options). The consumer writes the parts they want as children, in the order they want. In the Panel, the root shares state with its parts through a context (not every compound component does).
flowchart TD
Root["Panel root: value, onChange, onCommit"] --> Ctx["Context shared with the parts"]
Ctx --> Body["Panel.Body"]
Ctx --> Options["Panel.Options"]
Ctx --> Toggle["Panel.Toggle"]
Ctx --> Actions["Panel.Actions"]
Actions -. "registers itself on mount" .-> Ctx
1. Composition over props, and settle it before the first release
A small composite panel arrived in review with four booleans: showHint, showOptions, showToggle, showActions, rendering blocks in a fixed order. It merged like this instead:
<Panel value={value} onChange={preview} onCommit={save}>
<Panel.Body />
<Panel.Options /> {/* move this line to reorder the blocks */}
<Panel.Toggle>Set as default</Panel.Toggle>
<Panel.Actions />
</Panel>
The argument that holds: ordering. Not one of those flag combinations lets a consumer put the options above the body. With composition it's a line move. That one is unanswerable.
The second argument: consistency, and it's stronger than it looks. 30-plus components in the library already use the compound pattern, 17 of them sharing state through a context. The flag-driven panel was the deviation. In a library, matching the established shape is worth more than any individual API being marginally nicer.
The argument I demoted. The one I'd have made a year ago is "four booleans is sixteen combinations to test". That sounds good and it's mostly rhetoric: nobody supports 2⁴, there are three or four real configurations and four snapshot tests cover them. Don't lead with the number.
Two things I have to concede, because a reviewer pushed on them and was right.
First, compound parts give you no compile-time guarantee that a required part is present. A consumer can render <Panel> with no .Body and get a silently broken component. Four booleans with sane defaults cannot produce that. There is no clean fix, only a runtime warning.
Second, the commit rule (whether an edit is saved at once or waits for a validation step) gets more complex, not less. With flags, the root read showActions to know whether an edit commits immediately. Once composition owns that, the root can't see whether a validation step exists, so .Actions registers itself with the context on mount.
Use a counter, not a boolean. A remount that mounts the new part before unmounting the old must never momentarily read as "no validation step": a boolean would be cleared by the old part leaving while the new one is there. A counter also survives StrictMode's +1 / -1 / +1 (it mounts, unmounts and mounts the part again).
So the honest trade is:
| You buy | You pay |
|---|---|
| Ordering and consistency | A registration protocol and a lost compile-time guarantee |
I'd make that trade again, but it isn't free and I shouldn't pretend it is.
Do it before you publish. Deleting those four props cost nothing because the component had never shipped. After a release, every flag needs a deprecated alias and a full deprecation cycle. Compound-vs-flags is a five-minute conversation before the first release and a quarter of migration afterwards.
2. The design brief describes the layout, not the API
Those four booleans came straight from the brief (what design asked for), which said "independent toggles, fixed order".
That is UX describing the visual result. It does not constrain the technical contract. A fixed visual order does not require a fixed structural one, and the two are trivially compatible here: consumers write the blocks in the order the design shows.
Design owns what it looks like. It does not own the shape of the API. Worth saying out loud in review, calmly, because the brief gets quoted as if it settled the question.
3. Follow the house pattern, not the minority
A new component family (a root and its parts, like the panel) first shipped as a plain object namespace, copied from an existing component that does exactly that. The counts below compare two ways of grouping a family's parts:
Object.assign(Component, { ...subs }): the root is a component you can render, with the parts attached to it as properties.- A pure object literal: a plain object that only holds the parts and is not a component itself.
The component it was copied from is in the minority. At the time:
| How the parts are grouped | Components |
|---|---|
Object.assign(Component, { ...subs }) |
31 |
| Pure object literal | 6 |
It's 35 now, the counts drift.
Copying your neighbours is normally the right instinct in a large codebase. It is also how you inherit the one deviation instead of the convention.
So it became Widget = Object.assign(WidgetRoot, { A, B, Legend }), with no redundant Widget.Container, because making the root be the container costs nothing: the shared highlight context resolves to null outside (a part outside the root gets no context), and the parts already rendered standalone.
The rule: before you copy a pattern from a sibling component, count how many components do it the other way.
One card grid, three rules (4 to 6)
Rules 4 to 6 come from the same piece of work: a card grid that needed sorting, filtering and selection.
4. Read the code before you pick the component
The obvious answer was the library's table component in grid mode. Reading it killed that. First, four capability gaps:
- It cannot filter at all.
enableColumnFilters: falseis hard-wired and no filtered row model is registered: the table never sets up a filtering step. Of the three capabilities that motivated the choice, it supplies two. - No controlled sorting prop, only an initial value plus a change notification, so the page can't set the sort order the way
valuesets an input. The column header row is then the only sort affordance (the only place a user can sort), which puts a table header above a card grid whose columns don't align with it. Hide the header and the grid becomes unsortable. - Grid mode requires virtualization and a fixed pixel height. Virtualization means only mounting the rows that are on screen. The consumers here live in panels that grow with content.
- At one column, grid mode silently degrades to table rows. That footgun is present in the library's own reference story (the example the library publishes for the component).
Then the real exhibit, a design defect:
The row template is discovered by duck-typing. The parent looks through its children for one that has a row prop ('row' in child.props). That forces every call site to write row={undefined as unknown as Row<T>} just to be recognised: pass undefined and tell the type system it is a row. A cast lie, visible in the library's own story file. And because discovery tests typeof child.type === 'function', the template can never be forwardRef (a forwardRef component is not a plain function).
The first four are capability gaps you could argue about; a public API that requires consumers to lie to the type system to be seen is a design defect you can't.
The answer was a headless table (table logic with no UI of its own) owned by the page, handing the design system rows already sorted and filtered. Not a new API, a documented pattern with a reference story and a test.
The rule: a component's reputation is not its contract. Open it.
5. The real constraint is rarely the one being argued about
The standing argument for routing that grid through the table component was virtualization, for scale.
At a few hundred cards the binding constraint (what actually limited the page) was not the DOM. It was the image component, which fetches every source on mount and cannot be made lazy by construction. Virtualization only masks that by not mounting off-screen rows. The correct fix is in the image component, and until it lands, no grid substrate changes the request count.
Where this flips, because a rule without a boundary isn't a rule:
| Cards on one screen | What binds | What to do |
|---|---|---|
| A few hundred | The image component, not the DOM | Fix the image component; virtualization only masks it |
| Thousands, and the image component still preloads eagerly | Request count, but also DOM node count and memory | Grid-mode virtualization becomes the pragmatic containment |
The rule holds at hundreds, not at thousands.
Fixing the visible symptom in the wrong layer is how a design system accumulates components nobody can remove. But say where the layer changes.
6. Deleting before release costs a file, after release costs a cycle
While exploring that grid, a bridge component was built, tested, and proven to work by rendering. Then deleted before merge.
It worked. It was also a known API-shape violation shipped into the public surface (what the library exports for apps to use) for a path nobody had chosen. Its props type was already exported, so removing it after a release would have cost a deprecation cycle. Before release it cost one rm.
Being right about the direction is not enough to justify shipping the thing.
When a check doesn't cover what you think (rules 7 and 8)
7. Where a file lives decides which gates it gets, and that's a bug too
A gate is a check a change has to pass before it merges. Coverage collection (measuring how much of the code the tests run) was scoped to src/components/** with a 100% threshold, and the review gate for "is this new component justified" keyed off the same directory. So src/templates/ escaped both:
| Gate | src/components/** |
src/templates/ |
|---|---|---|
| Coverage, 100% threshold | Yes | No |
| Review: "is this new component justified" | Yes | No |
A template earns 100% of nothing.
Which inverts the incentives perfectly: a trivial badge variant in components/ gets both gates, and the most branch-heavy composite in the library gets neither. Easy to trip without meaning to, because a brief saying "new Template" points straight at templates/.
We split by weight: the stateful, branchy part to components/, only the page-level layout stays in templates/.
The slogan I withdrew. I used to summarise this as "when a gate has a blind spot, move the code, not the gate". I don't think that generalises, and I'd push back on anyone who quoted it at me now. Taken as a rule it says reshape the codebase around your CI configuration, and over a couple of years that gives you directory layouts encoding tooling history instead of architecture.
There are two separate facts here, and they deserve separate treatment:
- The split is right on its own merits. Stateful logic and page layout are different weights and belong apart. That argument needs no reference to coverage.
- The gate gap is a real second problem with its own ticket. Roughly ten existing templates are uncovered and always were. "Widening the glob would fail the build today" is an argument about migration cost, not about where code belongs.
I did the first and filed the second. Say both, don't let the gate be the justification for the architecture.
8. Pin what you assume with a test
Both parts of that family have a test asserting they render with no <Chart> ancestor, counting rendered marks (the shapes the chart draws) rather than checking the surface exists.
The reason is specific and worth getting right, because I stated it sloppily the first time. Under our Jest setup, a size-measuring component renders almost nothing:
- the ResizeObserver mock's
observeis a barejest.fn()that never invokes the callback; - jsdom's
getBoundingClientRect()returns all zeros; - so the rendering library bails before emitting the SVG.
Measured on one of them: 106 bytes of DOM, no <svg> at all, and nothing logged. How each assertion behaves on it:
| Assertion | On a component that cannot draw |
|---|---|
| The surface exists | Fails loudly, which is fine |
| "It rendered without throwing" | Passes vacuously |
| A non-empty container | Passes vacuously |
| A snapshot of the 106-byte div | Passes vacuously |
| Count the rendered marks | The only thing that proves it drew |
The dangerous assertions are the weaker ones. Count the marks.
Note that this is a property of our test setup, not of jsdom. With a ResizeObserver polyfill that actually fires, you won't reproduce it. Check your own mock before assuming you're safe.
The general rule: every property you decided to keep (an optional wrapper, standalone use, a backwards-compatible alias) needs a test that fails when someone removes it. Otherwise it isn't a decision, it's a coincidence.
What I learned
- Treat a shared library as an API with users who can't be forced to migrate. None of this is exotic: it is mostly being willing to have the awkward conversation before merge instead of the migration ticket after release.
- Lead with the argument that holds (ordering), not the one that sounds good (sixteen combinations), and say what your choice costs: composition isn't free.
- A component's reputation is not its contract, and the sibling you copy may be the minority. Open the code and count.
- A rule without a boundary isn't a rule. Fix a constraint in the layer that causes it, and say where the layer changes.
- Don't let a gate justify the architecture. Argue where code belongs on its own merits, and file the gate's blind spot as its own problem.
- Every property you decided to keep needs a test that fails when someone removes it. And if I can't make a gate fail on purpose, I don't know what it checks (see the November 2026 update).
Disagree with any of it? I'd genuinely like to hear the counter-case, especially on 4 and 7.
Update, November 2026
9. A gate you have never seen fail is not a gate. In September a change destructured a prop that had been declared on the wrong props type. Lint passed, the whole Jest suite passed, the build passed. CI went red only in the docs build: the tests compile one file at a time and the build only transpiles. I had been reading three green gates as "it compiles". A dedicated type check has run on every pull request since late October.
The architecture audit in October found the same shape twice more:
- Behind the 100% coverage threshold sat more than 500 coverage-ignore markers.
- More than 200 type errors in tests and stories were checked by no gate at all.
A threshold you can opt out of line by line is a number, not a gate.
I saw the same thing on a personal design system I maintain. Its accessibility gate fails on contrast violations, and it looked like it covered every theme. It scanned each story once, in the light theme and the default skin, because those were the test runner's defaults. Then I made it run every story in all four modes (light and dark, each in two skins):
| Before | Now | |
|---|---|---|
| Two demos with a custom link colour on a dark surface | CI passed both | Failed contrast at 1.64:1 and 1.25:1 |
| Results the checker flags as "needs review", where it can't decide | Passed silently | Fail |
| Whole test run | 16 seconds | 37 seconds, about 2.3 times slower |
Four modes meant four times the story tests. Worth it.
Before trusting a gate:
- Seed one case it must catch. For the contrast gate that is a deliberately bad pairing at 1.93:1.
- Watch the suite go red.
- Revert.
The seed has to go back in whenever the gate's plumbing changes. If I can't make a gate fail on purpose, I don't know what it checks.