13 min read
Aaron ReismanAaron 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 meet. When the two don’t agree, the UI feels off. When they do, the whole product feels considered.

This is how I build component systems. Part of it is TypeScript discipline and part of it is interaction design. All of it is aimed at code that still makes sense in two years.

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

Part 1: six promises

Before I write code, I make six promises to myself and the people I work with:

  1. Every component has one job (↓)
  2. Public APIs are forever (↓)
  3. Complexity should grow naturally (↓)
  4. No unnecessary abstractions (↓)
  5. Names tell stories (↓)
  6. Props are contracts, not configuration dumps (↓)

Each heading below matches one promise. Break any of them and maintenance turns into 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. A default export lets every file pick its own name for the import. A named export makes everyone use the same one.

  // ✅ 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.

Put export on the function itself, not in a list at the bottom of the file, 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 with one file. Add structure when the code asks for it. When the internals grow, turn the file into a folder with an index.ts that exports the same things. Code that imports the component 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

Destructure the props you use, right in the signature, and forward the rest. Don’t keep a props object around “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} />;
}

In the last Button, children travels inside htmlProps, because it goes straight to the <button>. Destructure children only when you put it somewhere else, like an inner wrapper:

  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

A good component system reads like a map. The names 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?

Some components are domain-specific: they know about your product’s data, like users or products, not just layout. For those, put the resource name in front of the pattern name (User + Card = UserCard):

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

Generic UI primitives like Card and Button belong in the design system, the shared component library. Domain components live next to the feature that uses them. 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
}

A general-purpose library like Material UI has to accept className and style overrides, because it can’t know your design. Your own components can. Keep the styling choices inside them, 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.
  • Give each package one entry point (one index.ts). It’s easier to remember, and it helps tree-shaking, the bundler step that drops code you don’t use.
  • 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. Two interface declarations with the same name silently merge into one (TypeScript calls this declaration merging), and component libraries usually don’t want that.

  // ⚠️ 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 a props type by name.

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

If the props type behind Card is renamed or reshaped, ListProps follows along without an import change.

Style belongs to the component

Components should own their styling. The style comes from the component’s state, not from whatever class the caller passes in.

  // ✅ 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 one when it solves a problem you have, not because a blog post said so. Including this one.

One interface, many presentations

When the same data needs multiple presentations (pricing cards, media tiles, dashboards), keep one public component. Inside it, pick an implementation based on a prop. This is the strategy pattern: each variant is a separate component, and a small selector chooses one.

  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} />;
  }
}

Callers only ever see PricingCard. The shared pieces stay private, so a brand update reaches every variant without copy and paste.

Compose instead of adding flags

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. In practice, each one is a requirement the caller has to remember. 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

Generic UI components should know nothing about business rules. Domain components should know nothing 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);
  // ...
}

Hold those boundaries and the design system stays stable while product teams move fast. (The sort in that last example also mutates the users prop in place, which is one more reason to keep it out of the UI layer.)

Part 5: putting it together

Here is how the pieces combine for a user-management screen. Start with small components that each do one job:

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

Move them into a folder 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

Then design the outer interface as if it were a product:

  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
}

Callers get one entry point, and everything inside can change without them noticing.

A quick recap

  • 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" />

You can read the second one out loud: “Render this user, allow edits, keep it compact.” Multiply that by every call site in a codebase.

Your next steps

  1. Refactor one component today. Pick the worst one, apply two of these promises, and 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.