How to scale a UI codebase without it rotting: composition over configuration, presentational vs container split, prop boundaries, design tokens, and a reusable component library.
Open the design-system Slack of any growing company and you will eventually find this message: "hey, does anyone know what isCompactSecondaryGhost does?" The Button started life as a humble little thing. Then someone needed a loading state, so they added isLoading. Then an icon, so hasIcon and iconPosition. Then a destructive variant, a ghost variant, a compact size, a full-width mode, a tooltip-on-disabled hack. Two years later the type signature is a wall.
Nothing about that compiles wrong. <Button isPrimary isSecondary isGhost /> typechecks perfectly and renders nonsense. The component has become a configuration language nobody designed on purpose. This article is about the discipline that prevents it: composition over configuration, clear component boundaries, prop design that closes invalid states, design tokens, and how all of it rolls up into a reusable component library, a real design system instead of a junk drawer.
Who this is for
You ship features in a React/Vue/Svelte codebase and you have felt the rot, components that fork into copies, props that contradict each other, "don't touch that file" comments. You want the senior-level mental models for keeping a UI codebase healthy as the team and surface area grow. Some component framework experience assumed; the principles are framework-agnostic.
The one principle: compose small pieces
Compose small pieces; don't configure giant ones. Every boolean prop you add is a branch inside the component, every slot you expose is a branch you handed to the caller.
A configurable component tries to anticipate every use case and exposes a prop for each one. The component owns all the variation, so it grows without bound. A composable component does one small job well and lets callers assemble it with siblings and children to get variation. The complexity moves out to the call site, where it is visible and local, exactly where the person who needs the variation is already standing.
A single molded plastic toy, one fixed shape, no partsA giant component with 30 props: every new need means re-molding the whole thing
A box of LEGO bricks, small, uniform, snap togetherSmall primitives (Box, Text, Stack, Button) you compose into anything
Want a castle and a spaceship from the same bricksSame primitives compose into a form, a card, a toolbar, no new code in the primitive
A brick has studs in standard positions so any two fitProps and tokens are the standard interface that lets pieces snap together predictably
Why composition scales and configuration doesn't.
LEGO won because the *interface* between bricks is small and uniform. That is the entire trick. Your primitives should have small, uniform interfaces too, then features are assembled, not authored from scratch each time.
The picture: how a UI codebase layers
A healthy frontend isn't a flat pile of components, it's a layered dependency graph. Lower layers know nothing about higher ones. Tokens don't know about buttons; buttons don't know about the checkout page. Dependencies point one way, down.
Design tokens flow up into primitives, which compose into reusable components, which compose into features and pages. Each layer depends only on the layer below.
1
Define tokens
Name your raw decisions once: `color.brand.500`, `space.4`, `radius.md`, `font.size.lg`. No component hardcodes a hex or a pixel value, they reference tokens.
2
Build primitives on tokens
A `Box` maps props to token-backed styles. A `Stack` lays out children with a token-based gap. Primitives are the only layer that touches raw CSS.
3
Compose reusable components
`Button`, `Input`, `Card` are built from primitives. They encode brand decisions (a Button always uses brand color + md radius) but stay generic about content.
4
Assemble features
`LoginForm` composes `Input` + `Button` + `Stack`. It knows about your domain; the components below it do not.
5
Compose pages
Routes wire features together. By the time you reach a page, you are arranging blocks, not writing CSS or fixing a Button.
6
Theme by swapping tokens
Dark mode and white-label brands change token *values*, not component code. The whole tree re-themes for free because nothing below references a raw value.
Presentational vs container components
Within the component layer there's a second split worth keeping in your head, even in a hooks-everywhere world. Presentational components take data and callbacks via props and render UI, no data fetching, no global state, no business rules. Container (or "connected") components know *where* data comes from, they call hooks, read stores, fire mutations, and hand plain props down to presentational children.
The payoff is testability and reuse. A presentational UserCard({ name, avatarUrl, onFollow }) renders identically in Storybook, in a test, and in three different features, because it has no idea where name came from. The container ConnectedUserCard does the useUser(id) plumbing and renders <UserCard {...user} onFollow={follow} />. Keep the wiring thin and shove all the actual UI into the dumb component. Modern code rarely needs two literal files for this, but the *boundary*, "this component fetches, that one only renders", is what keeps things sane.
Pro tip
Rule of thumb: if a component both calls `useQuery`/`useStore` *and* contains real layout/markup, it's doing two jobs. Split the data-fetching shell from the presentational core. The core becomes trivially reusable and your design system can ship it.
Composition vs configuration: the trade-offs
Configuration isn't *always* wrong, a variant="primary" enum prop is fine and good. The danger is when configuration is used to bolt on *structural* variation that should have been composition. Here's how to tell them apart by their symptoms.
Dimension
Configuration (prop-driven)
Composition (slot/children-driven)
Adding a use case
Add another prop + an internal branch
Caller arranges existing pieces, no edit to the component
Symptom of overuse
Prop explosion; mutually exclusive booleans
Deep nesting at call sites if primitives are too granular
Every design tweak edits this file and adds a branch. Now the composable version: Card owns *only* the box (background, radius, shadow, padding, all from tokens) and exposes slots via children and sub-components.
Notice what happened. Card will never grow another prop for a new layout. A wishlist card, a comparison card, an empty-state card, all of them reuse the same Card and arrange different children. And Button keeps a single variant enum instead of ten booleans, because enums make invalid states unrepresentable: you literally cannot pass variant="primary" variant="ghost". That is prop design as a safety rail, model the finite axes as enums, the open-ended structure as children.
Watch out
Prop boundary rule: a component's props are its public API. Once teams depend on a prop, removing it is a breaking change. Add props reluctantly, name them for *intent* (`tone="danger"`) not implementation (`color="#e11"`), and prefer `children` over a `content` prop whenever the slot might hold arbitrary markup.
Design tokens: the source of truth
A design token is a named design decision stored as data, not baked into code. Instead of #d97706 scattered across forty files, you have color.brand.500 defined once. Tokens are what let a design system stay consistent and re-theme without a rewrite. They usually come in two tiers: primitive tokens (the raw palette, purple.500 = #d97706) and semantic tokens (the meaning, color.action.primary = purple.500). Components reference *semantic* tokens, so you can repoint color.action.primary to a new shade and every button updates.
tokens.ts, semantic on top of primitive
tsx
// Primitive tier: raw values, no meaning yet.exportconst palette = {
purple500: "#d97706",
pink500: "#ec4899",
gray900: "#111827",
gray50: "#f9fafb",
} asconst;
// Semantic tier: meaning, mapped to primitives. Components use THESE.exportconst tokens = {
colorActionPrimary: palette.purple500,
colorTextDefault: palette.gray900,
colorSurface: palette.gray50,
spaceSm: "8px",
spaceMd: "16px",
radiusMd: "8px",
} asconst;
// Dark mode = a different semantic mapping, same primitives + components.exportconst darkTokens = {
...tokens,
colorTextDefault: palette.gray50,
colorSurface: palette.gray900,
} asconst;
In practice these are emitted as CSS custom properties (--color-action-primary) so theme switching is a single class on <html> and costs nothing at runtime. Tools like Style Dictionary turn one token source into CSS, JS, iOS, and Android outputs, one decision, every platform. The point isn't the tooling; it's the discipline: no raw values below the token layer.
From component library to design system
People use "component library" and "design system" interchangeably, but they aren't the same. A component library is the code, the reusable Button, Card, Input. A design system is that library *plus* the tokens, the usage guidelines, the accessibility contract, the documentation, and the shared vocabulary between design and engineering. The library is the artifact; the system is the agreement.
Tokens, the single source of design truth (covered above).
Primitives + components, composable, token-backed, accessible by default (focus rings, ARIA roles, keyboard handling baked in so feature teams can't forget).
Documentation, live examples (Storybook), prop tables, and "do / don't" guidance. Undocumented components get re-invented.
Contribution model, how a new component gets proposed, reviewed, and promoted from a feature into the shared library. Without this, everyone forks.
Versioning, semver on the package so breaking a prop is a deliberate, communicated event, not a surprise on Monday.
Start smaller than you think. You do not need 80 components on day one. Ship tokens + five primitives + the four or five components every feature actually uses (Button, Input, Card, Modal, Stack). Let real usage pull the next component into existence, promote it *after* it has been copy-pasted twice, not before.
Common mistakes that rot a codebase
Prop explosion. Bolting structural variation onto a component as booleans until it has 30 props and four mutually exclusive ones. Fix: reach for children/slots and the compound-component pattern before adding a prop.
Premature abstraction. Building a "flexible" generic component for a use case you have exactly once, guessing at the future API. The rule of three: don't extract a shared component until the third real usage shows you the actual shape. Wrong abstractions cost more than duplication.
No tokens. Hardcoded hex codes and pixel values sprinkled everywhere. Dark mode becomes a multi-week migration and brand consistency is a manual code review. Fix: a token layer from day one, even a tiny one.
Copy-paste components. "I'll just duplicate UserCard into AdminCard and tweak it." Now a bug fix has to be applied in two (then five) places and they drift. Fix: compose the shared core, vary via props/children, and a contribution model so the shared version is the easy path.
Mixing data and presentation. Components that fetch *and* render can't be reused or tested in isolation. Fix: keep the presentational core dumb and push the wiring to a thin container.
Takeaways
The whole article in nine lines
Compose small pieces; don't configure giant ones, every boolean prop is a branch you own forever.
LEGO beats a molded toy: small, uniform interfaces let you build anything without re-molding.
Layer the codebase: tokens → primitives → components → features → pages, dependencies pointing down only.
Split presentational (renders props) from container (knows where data lives), the dumb core is the reusable one.
Use enums for finite axes (size, variant) and children/slots for open-ended structure; that makes invalid states unrepresentable.
Treat props as a public API: name for intent, add reluctantly, prefer `children` over a `content` prop.
Design tokens are named decisions as data, primitive tier for raw values, semantic tier for meaning; no raw values below the token layer.
A component library is the code; a design system is library + tokens + docs + contribution model + versioning.
Start small and let real usage (the rule of three) pull abstractions into existence, never the other way around.
Where to go next
Architecture is one of three pillars of a healthy frontend. Once your components compose cleanly, the next questions are where state lives and how the visual layer is built underneath your tokens.
Continue the track: the Frontend Engineer path sequences these senior topics with the fundamentals beneath them.
Practice: pick your ugliest god-component and refactor it into a compound component this week. Pull the variation out to the call site and watch the prop list collapse.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.