The component manifesto
How I think about building component systems. Six principles, plus the practical patterns that go with them.
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:
- Every component has one job (↓)
- Public APIs are forever (↓)
- Complexity should grow naturally (↓)
- No unnecessary abstractions (↓)
- Names tell stories (↓)
- 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
- Refactor one component today. Pick the worst one, apply two of these promises, and see how it feels.
- Share the principles. They spread faster when a team talks about them together.
- Tune them. These are guardrails, not rules. Adjust for your product.
- 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.
