11 min read
Aaron Reisman Aaron Reisman

The Component Manifesto

How I think about building component systems. Six principles, plus the practical patterns that go with them.

Last updated

Components are where engineering and design end up meeting. When they don’t agree, the UI feels off. When they do, the whole product feels considered.

This is how I build component systems. The patterns are part TypeScript discipline and part interaction design, written for code that ages well.

The examples are React, but the ideas work in any component framework. Take the principles, swap the syntax.

Part 1: six promises

Before touching code, I make six promises to myself and my collaborators:

  1. Every component has one job ()
  2. Public APIs feel permanent ()
  3. Complexity grows only when you actually need it ()
  4. No abstractions without a reason ()
  5. Names tell you what something is ()
  6. Props are contracts, not configuration dumps ()

Break any of these and maintenance becomes an archaeological dig.

Principle 1: every component has one job

If you can’t describe a component in one sentence, it’s doing too much. Smaller components are easier to test, easier to redesign, easier to swap out, and they age better.

  type User = {
  id: string
  name: string
  avatar: string
  role: string
}

type UserCardProps = {
  user: User
  onEdit?: (id: string) => void
}

// ✅ Clear purpose: "Displays a user's information"
export function UserCard({ user, onEdit }: UserCardProps) {
  return (
    <article className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.role}</p>
      {onEdit ? <button onClick={() => onEdit(user.id)}>Edit</button> : null}
    </article>
  )
}

Contrast that with the kitchen-sink version:

  // ❌ Unclear purpose: tries to be everything
export function UserComponent({ user, showEdit, editText, layout, ...props }) {
  // Component that tries to be a card, a list item, and a form all at once
}

Focus keeps things honest. Compose smaller pieces instead of teaching one component every possible variation.

Principle 2: public APIs are forever

Exports are promises. Name them carefully and make it obvious what’s public. Default exports invite chaos. Named exports keep things consistent.

  // ✅ Named exports enforce consistent naming
export function Button() {
  /* ... */
}
export function ButtonGroup() {
  /* ... */
}

// ❌ Default exports allow naming chaos
export default function Button() {
  /* ... */
}
// Someone can now import it as SubmitButton, Btn, or CoolButton.

Export at the definition site so readers don’t have to scroll 200 lines to find what’s public. Think of the API like a product. Versions can change. The contract shouldn’t surprise people.

Principle 3: complexity should grow naturally

Start flat. Add structure when the code asks for it. When internals grow, move to a module. Consumers shouldn’t notice.

  // Stage 1: Single file
// components/button.tsx
export function Button({ variant, children }: ButtonProps) {
  return <button className={styles[variant]}>{children}</button>;
}

When the component earns more surface area:

  // Stage 2: Module with internal pieces
components/button/
├── button.tsx          // Main component
├── button-icon.tsx     // Internal helper
└── index.ts            // Public API: export * from './button'

Consumers keep importing from the same path. Internals can evolve quietly.

Principle 4: no unnecessary abstractions

Props aren’t keepsakes. Destructure what you actually use in the signature. Forward the rest. Don’t build wrapper objects “just in case.”

  // ❌ Creates unnecessary variable
export function Button(props: ButtonProps) {
  return <button onClick={props.onClick}>{props.children}</button>;
}

// ❌ Mixed access patterns cause drift
export function Button(props: ButtonProps) {
  const { variant, size } = props;
  return <button className={getStyles(variant, size)}>{props.children}</button>;
}

// ✅ Forward extra HTML attributes explicitly
export function Button({ variant, size, ...htmlProps }: ButtonProps) {
  return <button className={getStyles(variant, size)} {...htmlProps} />;
}

Only destructure children when you actually manipulate it:

  export function Card({ variant, children }: CardProps) {
  return (
    <div className={getStyles(variant)}>
      <div className="card-content">{children}</div>
    </div>
  );
}

Name the spread (htmlProps, domProps) so people can see at a glance what’s leaking through. Encapsulation should be deliberate.

Principle 5: names tell stories

Good component systems read like a map. The names should tell you the hierarchy and what each piece is for.

  // ✅ Clear parent-child relationships
export function Card() {
  /* ... */
}
export function CardHeader() {
  /* ... */
}
export function CardBody() {
  /* ... */
}
export function CardFooter() {
  /* ... */
}

// ❌ Ambiguous relationships
export function Card() {
  /* ... */
}
export function Header() {
  /* ... */
} // Header of what?

When a component is domain-specific, blend the resource with the pattern:

  export function UserCard() {
  /* ... */
}
export function UserAvatar() {
  /* ... */
}
export function ProductTable() {
  /* ... */
}

Generic UI primitives belong in the design system. Domain pieces live with their feature. The names should make the boundary obvious.

Principle 6: props are contracts, not configuration

Props should describe intent, not implementation details. If consumers need to pass className, the abstraction is leaking.

  // ✅ Props that tell a story
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'small' | 'medium' | 'large'
  disabled?: boolean
  onClick?: () => void
  children: React.ReactNode
}

// ❌ Props that expose implementation
type ButtonProps = {
  className?: string
  style?: React.CSSProperties
  buttonRef?: React.Ref
}

You know your product better than Material UI does. Encapsulate styling choices so a brand refresh touches one module, not 500 call sites.

  <Button variant="danger" onClick={handleDelete}>
  Delete
</Button>

Expose className and you’ll get one-off tweaks everywhere:

  <Button className="px-4 py-2 mt-4 -ml-2 shadow-lg">Delete</Button>

A few weeks later you’re digging through random spacing utilities. Keep the API focused. Let the internals carry the visual system.

Part 2: the language of components

Naming is interface design. Use it to show hierarchy and intent.

  • Keep files flat until complexity earns its keep.
  • Use folders only when internal pieces shouldn’t escape.
  • Export one surface per package. It’s better for tree-shaking and easier to remember.
  • Co-locate related files. The closer they sit, the easier it is to refactor them together.

Card, CardHeader, and CardFooter belong together. UserProfileCard lives with its feature. The name should tell you roughly where to find it in the repo.

Part 3: designing interfaces

Use the type system as a safety net

TypeScript will enforce your contracts if you set it up right. Prefer type for props. interface allows declaration merging, which component libraries usually don’t want.

  // ⚠️ Dangerous with interface - accidental merging
interface ButtonProps {
  variant: 'primary' | 'secondary'
}

interface ButtonProps {
  size: 'small' | 'medium' | 'large'
}
// Every Button now requires both.

// ✅ type prevents the footgun
type ButtonProps = {
  variant: 'primary' | 'secondary'
}

type ButtonProps = {
  size: 'small' | 'medium' | 'large'
}
// Error: Duplicate identifier 'ButtonProps'

When you need another component’s props, derive them with React.ComponentProps instead of importing custom types. It keeps things loose as APIs evolve.

  // ✅ Deriving types safely
type ListProps = {
  items: Array<React.ComponentProps<typeof Card>>
}

If Card becomes generic or gets renamed, your list keeps compiling.

Style belongs to the component

Components should own their styling. Style follows state, not the consumer’s mood.

  // ✅ Encapsulated styling
export function Alert({ severity, children }: AlertProps) {
  return (
    <div
      className={clsx('alert-base', {
        'alert-error': severity === 'error',
        'alert-warning': severity === 'warning',
        'alert-info': severity === 'info',
      })}
    >
      {children}
    </div>
  );
}

// ❌ Leaky styling
export function Alert({ className, children }: AlertProps) {
  return <div className={className}>{children}</div>;
}

Let components expose intent (severity), not CSS hooks. That’s how you ship a redesign without a global find-and-replace.

File layout, kept boring

Structure is a communication tool. Keep it boring.

  // Simple component
components/card.tsx

// When complexity increases
components/card/
├── index.ts           // Public exports
├── card.tsx           // Main component
├── card-header.tsx    // Internal component
├── card-body.tsx      // Internal component
└── utils.ts           // Internal utilities

Inside modules, prefer simple names (utils.ts, types.ts, constants.ts). Redundant prefixes slow readers down.

Part 4: patterns should solve problems, not create them

Patterns exist to make code clearer. Use them when they actually help, not because a blog post said so.

One interface, many presentations

When the same data needs multiple presentations (pricing cards, media tiles, dashboards), keep one public component and swap strategies internally.

  pricing-card/
├── index.ts                    // Public API (exports PricingCard)
├── pricing-card-header.tsx     // Shared internal component
├── pricing-card-price.tsx      // Shared internal component
├── pricing-card-features.tsx   // Shared internal component
└── pricing-card/               // Strategy implementations
    ├── index.tsx               // Strategy selector
    ├── default-pricing-card.tsx
    ├── featured-pricing-card.tsx
    └── compact-pricing-card.tsx
  // pricing-card/pricing-card/index.tsx
export function PricingCard({ plan, variant = 'default' }: PricingCardProps) {
  switch (variant) {
    case 'featured':
      return <FeaturedPricingCard plan={plan} />;
    case 'compact':
      return <CompactPricingCard plan={plan} />;
    default:
      return <DefaultPricingCard plan={plan} />;
  }
}

Shared internals stay private. Brand updates hit every variant without copy and paste.

Let data drive behavior

Compose UI primitives. Don’t turn components into control panels full of flags.

  // ✅ Composable UI components
<Card>
  <CardHeader>
    <Avatar user={user} />
    <Text variant="title">{user.name}</Text>
  </CardHeader>
  <CardBody>
    <Text>{user.bio}</Text>
  </CardBody>
  <CardFooter>
    <Button onClick={handleEdit}>Edit Profile</Button>
  </CardFooter>
</Card>

// ❌ Configuration soup
<UserProfileCard
  user={user}
  showAvatar={true}
  showBio={true}
  showEditButton={true}
  editButtonText="Edit Profile"
  onEdit={handleEdit}
/>

Boolean props look like flexibility, but really they just hide requirements. Let the data shape the interface.

Domain components can still be one big component. They just take structured data instead of a panel of switches.

  export function UserProfileCard({ userProfile, onEdit }: UserProfileCardProps) {
  return (
    <Card>
      <CardHeader>
        <Avatar src={userProfile.avatar} />
        <Text variant="title">{userProfile.name}</Text>
        {userProfile.role === 'admin' ? <Badge>Admin</Badge> : null}
      </CardHeader>
      <CardBody>
        <Text>{userProfile.bio}</Text>
      </CardBody>
      {onEdit ? (
        <CardFooter>
          <Button onClick={() => onEdit(userProfile.id)}>Edit Profile</Button>
        </CardFooter>
      ) : null}
    </Card>
  );
}

Let data shape the props

Model props after the data you actually have. If a component can’t tell what’s required versus optional, neither can your teammates.

  // ✅ Component shaped by its data
type Product = {
  id: string
  name: string
  price: number
  image: string
}

type ProductCardProps = {
  product: Product
  onAddToCart?: (productId: string) => void
}

export function ProductCard({ product, onAddToCart }: ProductCardProps) {
  // Clear contract, easy branching
}

// ❌ Guessing game
type ProductCardProps = {
  name?: string
  price?: number
  image?: string
  showPrice?: boolean
  pricePrefix?: string
}

Keep UI pure

UI layers should be dumb about business rules. Domain layers should be dumb about CSS.

  // ✅ Pure UI component
export function Table<T>({ data, columns, onRowClick }: TableProps<T>) {
  // Generic table that works with any data
}

// ✅ Domain component
export function UserTable({ users }: UserTableProps) {
  return (
    <Table data={users} columns={userColumns} onRowClick={handleUserClick} />
  );
}

// ❌ Domain logic leaking into UI package
export function UserTable({ users }: UserTableProps) {
  const sortedUsers = users.sort((a, b) => a.createdAt - b.createdAt);
  // ...
}

If you hold those boundaries, your design system stays stable while product teams move fast.

Part 5: putting it together

Imagine a user-management surface:

  // 1. Start with purpose-built components
export function UserCard({ user, onEdit }: UserCardProps) {
  /* ... */
}
export function UserAvatar({ user }: UserAvatarProps) {
  /* ... */
}
export function UserList({ users, onSelectUser }: UserListProps) {
  /* ... */
}

Organize them only when the module grows:

  user-management/
├── index.ts              // Public API exports only UserManagement
├── user-management.tsx   // Main component
├── user-card.tsx         // Internal component
├── user-avatar.tsx       // Internal component
└── types.ts              // Internal types

Design the outer interface like a product surface:

  type UserManagementProps = {
  users: User[]
  onUpdateUser: (user: User) => void
  variant?: 'compact' | 'detailed'
}

export function UserManagement({ users, onUpdateUser, variant = 'detailed' }: UserManagementProps) {
  // Adapt behavior based on the variant, not boolean toggles
}

One entry point, and the composition stays predictable. That’s the goal.

A quick recap

Here’s the arc:

  • Foundation. Components stay single-purpose. Public APIs hold their shape.
  • Language. Names show hierarchy. Exports define what’s public.
  • Interfaces. Types, props, and styling cooperate to hide complexity.
  • Patterns. Strategies, composition, and data-driven design tame variation.
  • Architecture. File structures and domain boundaries hold everything together.

What it looks like in practice

Apply the principles and the call sites change:

  // Before: a mess of concerns
<UserCard
  className="mt-4 px-6"
  showAvatar={true}
  showBio={false}
  avatarSize="large"
  bioMaxLength={100}
  onEditClick={handleEdit}
  editButtonText="Edit Profile"
/>

// After: clear and maintainable
<UserCard user={user} onEdit={handleEdit} variant="compact" />

The second one reads cleanly. “Render this user, allow edits, keep it compact.” That kind of clarity adds up across a codebase.

Your next steps

  1. Refactor one component today. Pick the worst one, apply two of these promises, see how it feels.
  2. Share the principles. They spread faster when a team talks about them together.
  3. Tune them. These are guardrails, not rules. Adjust for your product.
  4. Write down the why. Future teammates need to know why a pattern exists, not just how to use it.

Components are the vocabulary of your product. Write them with care and the rest of the interface gets a lot easier to build.

About the author

Aaron Reisman

Aaron Reisman

Aaron Reisman is a software engineer who writes about React, Elixir, and the parts of products that decide how they actually feel.

More like this

9 min read

How MasonryKit is built

A walkthrough of MasonryKit and the reasoning behind its three packages: @masonrykit/core for pure math, @masonrykit/browser for DOM utilities, and @masonrykit/react for the hook. Plus the abstractions in each layer worth borrowing for your own libraries.