DEV Community

Cover image for Component Architecture & Design Systems
Sri Balaji
Sri Balaji

Posted on Originally published at thesimplifiedtech.com

Component Architecture & Design Systems

⚡ TL;DR: Kill the Button with thirty boolean props by favoring composition over configuration. Split presentational from container components, set clear prop boundaries, and make design tokens the single source of truth so the codebase scales instead of rotting.

Contents

The Button with thirty boolean props

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.

Button.tsx (the horror)

interface ButtonProps {
  isPrimary?: boolean;
  isSecondary?: boolean;
  isGhost?: boolean;
  isDanger?: boolean;
  isCompact?: boolean;
  isLarge?: boolean;
  isFullWidth?: boolean;
  isLoading?: boolean;
  hasIcon?: boolean;
  iconLeft?: boolean;
  iconRight?: boolean;
  isDisabled?: boolean;
  showTooltipWhenDisabled?: boolean;
  isPill?: boolean;
  isOutlined?: boolean;
  // ...and 15 more, half of which are mutually exclusive
}
Enter fullscreen mode Exit fullscreen mode

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.

The whole article in one line

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.

In the real world In tech
A single molded plastic toy, one fixed shape, no parts A giant component with 30 props: every new need means re-molding the whole thing
A box of LEGO bricks, small, uniform, snap together Small primitives (Box, Text, Stack, Button) you compose into anything
Want a castle and a spaceship from the same bricks Same 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 fit Props 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. Ea

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.

💡 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
Invalid states Easy to create (isPrimary isGhost) Hard to create, structure enforces validity
Who owns complexity The component (grows without bound) The call site (local and visible)
Best for Finite, enumerable variation (size, color) Open-ended, structural variation (layout, content)
Failure mode A god component nobody dares touch Boilerplate if you under-abstract

Reading the symptoms: when a prop should have been a slot.

Over-propped vs composable: the code

The classic example is a Card. The configured version tries to own every layout decision through props. Watch how fast it rots:

Card.tsx, the configured (rotting) version

interface CardProps {
  title?: string;
  subtitle?: string;
  imageUrl?: string;
  imagePosition?: "top" | "left";
  badgeText?: string;
  badgeColor?: string;
  footerText?: string;
  primaryActionLabel?: string;
  onPrimaryAction?: () => void;
  secondaryActionLabel?: string;
  onSecondaryAction?: () => void;
  showDivider?: boolean;
  isHighlighted?: boolean;
  // every new design = a new prop + a new branch in here
}

function Card(props: CardProps) {
  return (
    <div className={props.isHighlighted ? "card card--hl" : "card"}>
      {props.imageUrl && props.imagePosition === "top" && (
        <img src={props.imageUrl} />
      )}
      {props.badgeText && (
        <span style={{ background: props.badgeColor }}>{props.badgeText}</span>
      )}
      {props.title && <h3>{props.title}</h3>}
      {props.subtitle && <p>{props.subtitle}</p>}
      {props.showDivider && <hr />}
      {/* ...footer, two actions, image-left branch, etc. */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Card.tsx, the composable version

// Card owns the container styling only. Structure is the caller's job.
function Card({ children, highlighted = false }: {
  children: React.ReactNode;
  highlighted?: boolean;
}) {
  return (
    <div data-highlighted={highlighted} className="card">
      {children}
    </div>
  );
}

// Small, focused sub-components, the "compound component" pattern.
Card.Header = ({ children }: { children: React.ReactNode }) => (
  <div className="card__header">{children}</div>
);
Card.Body = ({ children }: { children: React.ReactNode }) => (
  <div className="card__body">{children}</div>
);
Card.Footer = ({ children }: { children: React.ReactNode }) => (
  <div className="card__footer">{children}</div>
);
Enter fullscreen mode Exit fullscreen mode

ProductCard.tsx, composing at the call site

// The variation lives HERE, visible and local. No edit to Card needed.
function ProductCard({ product }: { product: Product }) {
  return (
    <Card highlighted={product.featured}>
      <Card.Header>
        <Badge tone="sale">Sale</Badge>
        <Heading level={3}>{product.name}</Heading>
      </Card.Header>
      <Card.Body>
        <img src={product.imageUrl} alt={product.name} />
        <Text tone="muted">{product.summary}</Text>
      </Card.Body>
      <Card.Footer>
        <Button variant="primary" onClick={buy}>Add to cart</Button>
        <Button variant="ghost" onClick={save}>Save</Button>
      </Card.Footer>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

⚠️ Warning: 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

// Primitive tier: raw values, no meaning yet.
export const palette = {
  purple500: "#d97706",
  pink500: "#ec4899",
  gray900: "#111827",
  gray50: "#f9fafb",
} as const;

// Semantic tier: meaning, mapped to primitives. Components use THESE.
export const tokens = {
  colorActionPrimary: palette.purple500,
  colorTextDefault: palette.gray900,
  colorSurface: palette.gray50,
  spaceSm: "8px",
  spaceMd: "16px",
  radiusMd: "8px",
} as const;

// Dark mode = a different semantic mapping, same primitives + components.
export const darkTokens = {
  ...tokens,
  colorTextDefault: palette.gray50,
  colorSurface: palette.gray900,
} as const;
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  • Where data lives: State Management in Frontend Apps, the container side of the presentational/container split, in depth.
  • The layer under your tokens: CSS Fundamentals: The Box Model and Layout, what your primitives are actually emitting.
  • 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.

Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.

Top comments (0)