Learning Track
Filter content by your role to see the most relevant sections
Component Architecture Basics
Learn the layered approach to component architecture: primitives, compounds, composers, and assemblies. Understand how composition becomes a governance strategy that channels complexity into legible, maintainable structures.
Why Component Architecture Matters
When design systems first take root, they begin with components: buttons, inputs, icons, toggles. The goal is consistency, but consistency alone doesn't explain why complexity creeps in. Over time, you notice the neat catalog breaks down: forms behave differently across contexts, toolbars overflow with actions, editors sprout feature walk-throughs, and pagination mutates with ellipses and compact modes.
The problem isn't that your system is "messy." The problem is that you're seeing composition at work. Complexity in digital interfaces rarely comes from primitives themselves—it emerges when small parts are combined, orchestrated, and pushed against application workflows.
To build systems that endure, you need a lens that helps you anticipate this layering before it manifests in code. The layered component methodology provides: a way to classify, compose, and govern components across four levels of scale—primitives, compounds, composers, and assemblies.
Core Concepts: The Four Layers
1. Primitives
Primitives are the ground floor: irreducible building blocks like buttons, text inputs, checkboxes, icons, and typographic elements. Their goals are stability, accessibility, and consistency. They should be as "boring" as possible.
Examples: Button, Input, Checkbox, Icon
Work of the system: naming, tokens, accessibility patterns
Pitfalls: bloated props, reinventing label or error logic inside each input
The paradox of primitives is that their importance is inversely proportional to their excitement. The most boring components—when standardized and consistent—enable the most creative outcomes at higher layers.
2. Compounds
Compounds bundle primitives into predictable, reusable groupings. They codify conventions and reduce repeated decision-making. Compounds emerge when teams repeatedly combine the same primitives in the same ways.
Examples: TextField (input + label + error), TableRow, Card, Chip
Work of the system: defining which sub-parts exist, providing safe variations
Pitfalls: "mega-props" that attempt to account for every variation
The compound layer is where convention becomes codified. Instead of asking designers and developers to reinvent the bundle every time, the system codifies the convention.
3. Composers
Composers orchestrate state, interaction, and context across multiple children. This is where systems meet complexity: modals, toolbars, message composers, pagination. They often contain compounds and primitives.
Examples: Modal, Form Field, Toolbar, Pagination, Rich Text Editor
Work of the system: governing orchestration, exposing slots, avoiding prop explosion
Pitfalls: burying orchestration in ad-hoc props instead of a clear context model
Composers exist because user interactions don't stop at a single element— they span across elements. They coordinate multiple states, flows, and roles.
4. Assemblies
Assemblies are application-specific flows encoded as components. They aren't universal system primitives; they're product constructs that use the system's primitives, compounds, and composers.
Examples: Checkout Flow, Project Board, Analytics Dashboard
Work of the system: provide the building blocks; assemblies live at the app layer
Pitfalls: accidentally "baking in" assemblies as universal components, which ossifies the system
Assemblies belong at the application layer, not the system layer. The system provides the language; assemblies are the sentences written in that language.
Meta-Patterns Across All Layers
Regardless of layer, three meta-patterns ensure scalability:
- Slotting & Substitution: Anticipate replaceable regions (children, slots, render props). This enables composition without breaking orchestration.
- Headless Abstractions: Separate logic (hooks, providers) from presentation (styled components). This enables reuse across platforms and contexts.
- Contextual Orchestration: Treat composers as state providers, not just visual containers. This enables complex interactions without prop drilling.
These aren't just coding tricks—they're governance strategies. They help a design system resist collapse under exceptions.
System Roles: How Layering Shapes System Success
Scalability Impact
Layered architecture enables systems to scale gracefully. When you need a new variant, you compose existing pieces rather than creating new components. Primitives provide stability, compounds codify conventions, composers orchestrate complexity, and assemblies remain at the product layer.
Governance Impact
Clear layer boundaries make governance easier. Primitive APIs change rarely because every downstream component depends on them. Compounds provide blessed combinations that prevent drift. Composers govern orchestration patterns. Assemblies stay out of the system entirely.
Team Autonomy Impact
Layered architecture enables team autonomy. Product teams can compose solutions inside system boundaries without waiting for new one-off components. The system team defines boundaries and patterns; product teams compose solutions within those boundaries.
Complexity Management Impact
The layered approach channels complexity into legible structures. A button is stable. A field is orchestrated. A toolbar overflows gracefully. A rich text editor governs the chaos of paste and plugins. Complexity isn't eliminated—it's governed.
Design & Code Interplay
The layered architecture bridges design and code. Let's examine how each layer manifests in actual component implementations.
Primitives: Minimal, Stable APIs
Primitives are the foundation layer. They have minimal APIs, stable interfaces, and single responsibilities. Here's how a primitive Button looks:
// Primitive: Button - minimal, stable API
export interface ButtonProps {
size?: 'small' | 'medium' | 'large';
variant?: 'primary' | 'secondary' | 'tertiary';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ size = 'medium', variant = 'primary', disabled, onClick, children }, ref) => {
return (
<button
ref={ref}
className={styles.button}
data-size={size}
data-variant={variant}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
);
// Primitive characteristics:
// - Single responsibility: renders a button
// - Minimal props: size, variant, disabled, onClick
// - Stable API: rarely changes
// - No dependencies on other components
// - Composable: can be used anywhereCompounds: Composed Primitives
Compounds combine primitives into common patterns. They provide blessed combinations that prevent drift. Here's how a compound TextField composes Input and Label:
// Compound: TextField - composes Input + Label
export interface TextFieldProps {
id?: string;
label?: React.ReactNode;
description?: React.ReactNode;
error?: React.ReactNode;
// ... Input props
}
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
({ id, label, description, error, ...inputProps }, ref) => {
const inputId = React.useId();
const resolvedId = id ?? `tf-${inputId}`;
return (
<div className={styles.field}>
{label && (
<Label htmlFor={resolvedId}>
{label}
</Label>
)}
<Input
id={resolvedId}
ref={ref}
aria-describedby={describedBy}
invalid={!!error}
{...inputProps}
/>
{description && (
<div className={styles.description}>
{description}
</div>
)}
{error && (
<div role="alert" className={styles.error}>
{error}
</div>
)}
</div>
);
}
);
// Compound characteristics:
// - Composes primitives (Input, Label)
// - Handles relationships (label association, error display)
// - Provides blessed pattern (field structure)
// - More complex than primitives, less than composers
// - Common enough to deserve a compoundComposers: Stateful Orchestration
Composers orchestrate complex interactions through context and state management. They provide slots for composition while managing orchestration logic. Here's how a composer Field works:
// Composer: Field - stateful orchestration with slots
const FieldContext = createContext<FieldContextValue | null>(null);
export const Field = Object.assign(FieldComponent, {
Label,
Error: ErrorText,
Help: HelpText,
});
const FieldComponent = React.forwardRef<HTMLDivElement, FieldProps>(
({ children, className }, ref) => {
const fieldContext = useFieldCtx();
return (
<FieldProvider value={fieldContext}>
<div
ref={ref}
className={styles.root}
role="group"
aria-labelledby={fieldContext.labelId}
data-status={fieldContext.status}
>
<div className={styles.header}>
<Field.Label />
</div>
<div className={styles.control}>
{children} {/* Slot for input component */}
{fieldContext.status === 'validating' && (
<Spinner size="sm" />
)}
</div>
<div className={styles.meta}>
<Field.Error />
<Field.Help />
</div>
</div>
</FieldProvider>
);
}
);
// Usage: Field orchestrates, user provides input
<Field>
<Input /> {/* Slot */}
</Field>
// Composer characteristics:
// - Manages state (validation, focus, errors)
// - Provides context for child components
// - Has slots for composition (children)
// - Orchestrates complex interactions
// - More complex than compounds, but still reusableAPI Design Differences Between Layers
Each layer has different API design constraints. Primitives expose props directly. Compounds manage relationships. Composers manage state and provide context:
// Primitive API: Direct props
<Button variant="primary" size="medium" onClick={handleClick}>
Submit
</Button>
// Compound API: Managed relationships
<TextField
label="Email"
description="Enter your email address"
error={errors.email}
value={email}
onChange={setEmail}
/>
// Composer API: Context + slots
<Field>
<Field.Label>Email</Field.Label>
<Input
value={email}
onChange={setEmail}
/>
<Field.Error>{errors.email}</Field.Error>
<Field.Help>Enter your email address</Field.Help>
</Field>
// Layer comparison:
// - Primitives: Simple props, no dependencies
// - Compounds: Managed props, composes primitives
// - Composers: Context + slots, manages stateComposition Patterns
Different layers use different composition patterns. Primitives use direct composition. Compounds use controlled composition. Composers use context-based composition:
// Pattern 1: Direct Composition (Primitives)
// User composes primitives directly
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" />
<Button onClick={handleSubmit}>Submit</Button>
</div>
// Pattern 2: Compound Composition (Compounds)
// Compound handles internal composition
<TextField
label="Email"
error={errors.email}
/>
// Pattern 3: Slot Composition (Composers)
// Composer provides slots, user fills them
<Card>
<Card.Header>
<Card.Title>Title</Card.Title>
</Card.Header>
<Card.Content>
Content here
</Card.Content>
<Card.Footer>
<Button>Action</Button>
</Card.Footer>
</Card>
// Pattern 4: Context Composition (Composers)
// Context manages state, slots provide flexibility
<Field>
<Field.Label>Email</Field.Label>
<Input /> {/* Context provides state */}
<Field.Error />
</Field>Real-World Impact
This layered architecture enables predictable scaling. When you need a new button variant, you add it to the primitive. When you need a new field pattern, you compose primitives into a compound. When you need complex orchestration, you create a composer with slots.
The layers create boundaries that guide decisions. Should this be a primitive? Does it need state management? Should it be reusable? The layer answers these questions, making architecture decisions predictable and scalable.
The layered architecture bridges design and code. Let's examine how each layer manifests in actual component implementations.
Primitives: Minimal, Stable APIs
Primitives are the foundation layer. They have minimal APIs, stable interfaces, and single responsibilities. Here's how a primitive Button looks:
// Primitive: Button - minimal, stable API
export interface ButtonProps {
size?: 'small' | 'medium' | 'large';
variant?: 'primary' | 'secondary' | 'tertiary';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ size = 'medium', variant = 'primary', disabled, onClick, children }, ref) => {
return (
<button
ref={ref}
className={styles.button}
data-size={size}
data-variant={variant}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
);
// Primitive characteristics:
// - Single responsibility: renders a button
// - Minimal props: size, variant, disabled, onClick
// - Stable API: rarely changes
// - No dependencies on other components
// - Composable: can be used anywhereCompounds: Composed Primitives
Compounds combine primitives into common patterns. They provide blessed combinations that prevent drift. Here's how a compound TextField composes Input and Label:
// Compound: TextField - composes Input + Label
export interface TextFieldProps {
id?: string;
label?: React.ReactNode;
description?: React.ReactNode;
error?: React.ReactNode;
// ... Input props
}
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
({ id, label, description, error, ...inputProps }, ref) => {
const inputId = React.useId();
const resolvedId = id ?? `tf-${inputId}`;
return (
<div className={styles.field}>
{label && (
<Label htmlFor={resolvedId}>
{label}
</Label>
)}
<Input
id={resolvedId}
ref={ref}
aria-describedby={describedBy}
invalid={!!error}
{...inputProps}
/>
{description && (
<div className={styles.description}>
{description}
</div>
)}
{error && (
<div role="alert" className={styles.error}>
{error}
</div>
)}
</div>
);
}
);
// Compound characteristics:
// - Composes primitives (Input, Label)
// - Handles relationships (label association, error display)
// - Provides blessed pattern (field structure)
// - More complex than primitives, less than composers
// - Common enough to deserve a compoundComposers: Stateful Orchestration
Composers orchestrate complex interactions through context and state management. They provide slots for composition while managing orchestration logic. Here's how a composer Field works:
// Composer: Field - stateful orchestration with slots
const FieldContext = createContext<FieldContextValue | null>(null);
export const Field = Object.assign(FieldComponent, {
Label,
Error: ErrorText,
Help: HelpText,
});
const FieldComponent = React.forwardRef<HTMLDivElement, FieldProps>(
({ children, className }, ref) => {
const fieldContext = useFieldCtx();
return (
<FieldProvider value={fieldContext}>
<div
ref={ref}
className={styles.root}
role="group"
aria-labelledby={fieldContext.labelId}
data-status={fieldContext.status}
>
<div className={styles.header}>
<Field.Label />
</div>
<div className={styles.control}>
{children} {/* Slot for input component */}
{fieldContext.status === 'validating' && (
<Spinner size="sm" />
)}
</div>
<div className={styles.meta}>
<Field.Error />
<Field.Help />
</div>
</div>
</FieldProvider>
);
}
);
// Usage: Field orchestrates, user provides input
<Field>
<Input /> {/* Slot */}
</Field>
// Composer characteristics:
// - Manages state (validation, focus, errors)
// - Provides context for child components
// - Has slots for composition (children)
// - Orchestrates complex interactions
// - More complex than compounds, but still reusableAPI Design Differences Between Layers
Each layer has different API design constraints. Primitives expose props directly. Compounds manage relationships. Composers manage state and provide context:
// Primitive API: Direct props
<Button variant="primary" size="medium" onClick={handleClick}>
Submit
</Button>
// Compound API: Managed relationships
<TextField
label="Email"
description="Enter your email address"
error={errors.email}
value={email}
onChange={setEmail}
/>
// Composer API: Context + slots
<Field>
<Field.Label>Email</Field.Label>
<Input
value={email}
onChange={setEmail}
/>
<Field.Error>{errors.email}</Field.Error>
<Field.Help>Enter your email address</Field.Help>
</Field>
// Layer comparison:
// - Primitives: Simple props, no dependencies
// - Compounds: Managed props, composes primitives
// - Composers: Context + slots, manages stateComposition Patterns
Different layers use different composition patterns. Primitives use direct composition. Compounds use controlled composition. Composers use context-based composition:
// Pattern 1: Direct Composition (Primitives)
// User composes primitives directly
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" />
<Button onClick={handleSubmit}>Submit</Button>
</div>
// Pattern 2: Compound Composition (Compounds)
// Compound handles internal composition
<TextField
label="Email"
error={errors.email}
/>
// Pattern 3: Slot Composition (Composers)
// Composer provides slots, user fills them
<Card>
<Card.Header>
<Card.Title>Title</Card.Title>
</Card.Header>
<Card.Content>
Content here
</Card.Content>
<Card.Footer>
<Button>Action</Button>
</Card.Footer>
</Card>
// Pattern 4: Context Composition (Composers)
// Context manages state, slots provide flexibility
<Field>
<Field.Label>Email</Field.Label>
<Input /> {/* Context provides state */}
<Field.Error />
</Field>Real-World Impact
This layered architecture enables predictable scaling. When you need a new button variant, you add it to the primitive. When you need a new field pattern, you compose primitives into a compound. When you need complex orchestration, you create a composer with slots.
The layers create boundaries that guide decisions. Should this be a primitive? Does it need state management? Should it be reusable? The layer answers these questions, making architecture decisions predictable and scalable.
Applied Example: Building a Form Field System
Let's build a form field system following the layered architecture approach:
Step 1: Start with Primitives
Build stable, boring primitives first:
// Good — Primitive: Input - stable, boring, predictable
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, invalid, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(styles.input, className)}
aria-invalid={invalid}
{...props}
/>
);
}
);
// Primitive characteristics:
// - Single responsibility: just input
// - No assumptions about context
// - Stable API (rarely changes)
// - Composable (can be used anywhere)
// Good — Primitive: Label - also stable
export const Label = ({ htmlFor, children, ...props }) => {
return (
<label htmlFor={htmlFor} {...props}>
{children}
</label>
);
};
// Good — Primitive: ErrorText - stable
export const ErrorText = ({ id, children, ...props }) => {
return (
<span id={id} role="alert" {...props}>
{children}
</span>
);
};Step 2: Create Compound Component
Compose primitives into a compound component:
// Good — Compound: TextField - codifies convention
export const TextField = ({
label,
error,
id,
...inputProps
}) => {
const inputId = id || useId();
const errorId = `${inputId}-error`;
return (
<div className={styles.field}>
<Label htmlFor={inputId}>
{label}
</Label>
<Input
id={inputId}
invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...inputProps}
/>
{error && (
<ErrorText id={errorId}>
{error}
</ErrorText>
)}
</div>
);
};
// Compound characteristics:
// - Codifies convention: label + input + error
// - Handles relationships between primitives
// - Provides accessibility (aria-describedby)
// - Standardizes spacing and layoutStep 3: Build Composer for Complex Cases
Create composer for complex form orchestration:
// Good — Composer: Field - orchestrates complexity
const FieldContext = createContext<FieldContextValue | null>(null);
export const Field = ({ children, error, required, ...props }) => {
const id = useId();
const errorId = `${id}-error`;
const contextValue = {
id,
errorId,
error,
required,
};
return (
<FieldContext.Provider value={contextValue}>
<div className={styles.field} {...props}>
{children}
</div>
</FieldContext.Provider>
);
};
// Field sub-components use context:
Field.Label = ({ children }) => {
const context = useContext(FieldContext);
if (!context) throw new Error('Field.Label must be inside Field');
return (
<Label htmlFor={context.id} required={context.required}>
{children}
</Label>
);
};
Field.Input = React.forwardRef<HTMLInputElement, InputProps>(
({ ...props }, ref) => {
const context = useContext(FieldContext);
if (!context) throw new Error('Field.Input must be inside Field');
return (
<Input
ref={ref}
id={context.id}
invalid={!!context.error}
aria-describedby={context.error ? context.errorId : undefined}
{...props}
/>
);
}
);
Field.Error = ({ children }) => {
const context = useContext(FieldContext);
if (!context) return null;
return (
<ErrorText id={context.errorId}>
{context.error || children}
</ErrorText>
);
};
Field.Help = ({ children }) => {
const context = useContext(FieldContext);
if (!context) return null;
return (
<span id={`${context.id}-help`} className={styles.help}>
{children}
</span>
);
};
// Usage:
<Field error={formErrors.email}>
<Field.Label>Email</Field.Label>
<Field.Input type="email" />
<Field.Error />
<Field.Help>We'll never share your email</Field.Help>
</Field>Step 4: Choose the Right Layer
Decide which layer to use based on complexity:
- Simple case: Use compound (TextField)
<TextField label="Email" error={errors.email} type="email" /> - Complex case: Use composer (Field)
<Field error={errors.email}> <Field.Label>Email</Field.Label> <Field.Input type="email" /> <Field.Error /> <Field.Help>We'll never share your email</Field.Help> </Field> - Edge case: Compose primitives directly
<div className={styles.customField}> <Label htmlFor="custom">Custom</Label> <Input id="custom" /> {/* Custom layout, custom error handling */} </div>
Step 5: Understand Layer Responsibilities
Each layer has clear responsibilities:
- Primitives: Single responsibility, stable APIs, composable
- Compounds: Codify conventions, handle common relationships, standardize patterns
- Composers: Orchestrate complex state, provide context, enable flexible composition
Real-World Impact
This layered architecture provides:
- Flexibility: Use the right layer for each use case
- Stability: Primitives rarely change, enabling system evolution
- Consistency: Compounds ensure standard patterns across the system
- Power: Composers handle complex cases without breaking simple ones
- Maintainability: Clear boundaries make system easier to understand and evolve
Constraints & Trade-offs
Layered architecture involves tradeoffs:
- Boring vs. Expressive: Primitives must be boring to be stable. Expressiveness belongs in compounds, composers, or assemblies. This can feel limiting but enables creativity at higher layers.
- Convention vs. Flexibility: Compounds codify conventions, which limits flexibility but prevents drift. Teams must decide when to use compounds versus composing primitives directly.
- Orchestration vs. Simplicity: Composers add complexity through orchestration but simplify usage through context. Simple use cases might feel over-engineered, but complex cases benefit from orchestration.
- System vs. Product: Assemblies belong at the product layer, not the system layer. This can feel restrictive but prevents system ossification.
The key is understanding when to operate at each layer and respecting layer boundaries. Primitives demand standards. Compounds demand conventions. Composers demand orchestration. Assemblies demand boundaries.
Component Anti-Patterns & Layer Misclassification
Understanding component anti-patterns helps avoid common mistakes that undermine architectural integrity:
1. Layer Misclassification
The Problem: Components placed in the wrong layer, breaking architectural boundaries.
// Anti-pattern: Compound as Primitive
// Input primitive that includes label logic
export const Input = ({ label, error, ...props }) => {
return (
<div>
<label>{label}</label>
<input {...props} />
{error && <span>{error}</span>}
</div>
);
};
// Problems:
// - Primitive doing compound work
// - Can't use Input without label/error
// - Breaks layer boundaries
// - Hard to compose flexibly
// Correct: Layer separation
// Primitive: just the input
export const Input = ({ ...props }) => {
return <input {...props} />;
};
// Compound: Input + Label + Error
export const TextField = ({ label, error, ...props }) => {
return (
<div>
<Label>{label}</Label>
<Input {...props} />
{error && <ErrorText>{error}</ErrorText>}
</div>
);
};
// Benefits:
// - Clear layer boundaries
// - Primitive can be used independently
// - Compound composes primitives
// - Flexible composition2. Prop Explosion
The Problem: Components with too many props, indicating wrong layer or poor abstraction.
// Anti-pattern: prop explosion
<UniversalComponent
type="button"
variant="primary"
size="medium"
icon="arrow"
iconPosition="left"
loading={false}
disabled={false}
fullWidth={false}
rounded={true}
shadow={true}
gradient={false}
animation="fade"
// ... 20 more props
/>
// Problems:
// - Too many decisions per usage
// - Hard to remember all props
// - Indicates wrong layer
// - Tries to be everything
// Correct: Composition over configuration
<Button variant="primary" size="medium">
<Icon name="arrow" />
Click me
</Button>
// Benefits:
// - Simple, clear API
// - Composition handles complexity
// - Right layer for the problem
// - Easy to understand3. Tight Coupling
The Problem: Components tightly coupled to specific implementations, preventing reuse.
// Anti-pattern: tight coupling
export const FormField = () => {
const [value, setValue] = useState('');
const [error, setError] = useState('');
// Hardcoded validation logic
const validate = () => {
if (!value.includes('@')) {
setError('Invalid email');
}
};
return (
<div>
<input value={value} onChange={(e) => setValue(e.target.value)} />
{error && <span>{error}</span>}
</div>
);
};
// Problems:
// - Validation logic baked in
// - Can't reuse for other fields
// - Tightly coupled to email
// - Hard to test
// Correct: Separation of concerns
export const TextField = ({ value, onChange, error, ...props }) => {
return (
<div>
<Input value={value} onChange={onChange} {...props} />
{error && <ErrorText>{error}</ErrorText>}
</div>
);
};
// Validation handled outside component
<TextField
value={email}
onChange={setEmail}
error={validateEmail(email)}
/>
// Benefits:
// - Reusable component
// - Validation logic separate
// - Easy to test
// - Flexible usage4. Wrong Layer for Problem
The Problem: Using the wrong layer for the problem, creating unnecessary complexity or constraints.
// Anti-pattern: Composer for simple problem
// Using Field composer for simple input
<Field>
<Field.Label>Email</Field.Label>
<Input />
<Field.Error />
</Field>
// Problems:
// - Over-engineered for simple case
// - Too much ceremony
// - Harder than needed
// - Wrong tool for job
// Correct: Right layer for problem
// Simple case: use Compound
<TextField label="Email" />
// Complex case: use Composer
<Field>
<Field.Label>Email</Field.Label>
<Input />
<Field.Error />
<Field.Help />
</Field>
// Benefits:
// - Right tool for the job
// - Simple cases stay simple
// - Complex cases have power
// - Clear when to use what5. Missing Abstractions
The Problem: Repeating patterns instead of creating reusable abstractions.
// Anti-pattern: repeating patterns
// Same pattern repeated everywhere
function EmailField() {
return (
<div>
<label>Email</label>
<input type="email" />
</div>
);
}
function PasswordField() {
return (
<div>
<label>Password</label>
<input type="password" />
</div>
);
}
// Problems:
// - Code duplication
// - Inconsistent implementations
// - Hard to maintain
// - Missed abstraction opportunity
// Correct: Abstract the pattern
export const TextField = ({ label, type, ...props }) => {
return (
<div>
<Label>{label}</Label>
<Input type={type} {...props} />
</div>
);
};
// Usage:
<TextField label="Email" type="email" />
<TextField label="Password" type="password" />
// Benefits:
// - Single source of truth
// - Consistent implementation
// - Easy to maintain
// - Reusable abstraction6. Over-Composition
The Problem: Creating unnecessary layers when simpler solutions would work.
// Anti-pattern: unnecessary abstraction
// Creating wrapper for simple case
export const EmailInput = () => {
return <TextField type="email" />;
};
// Problems:
// - Unnecessary abstraction
// - Adds no value
// - Extra layer to maintain
// - Over-engineering
// Correct: Use directly
<TextField type="email" label="Email" />
// Benefits:
// - Direct usage
// - No unnecessary layers
// - Clear intent
// - Simpler codebaseWarning Signs
Watch for these indicators of architectural problems:
- Prop explosion: Components with 20+ props indicate wrong layer or poor abstraction
- Layer violations: Primitives doing compound work or compounds doing composer work
- Code duplication: Same patterns repeated across components indicate missing abstractions
- Tight coupling: Components that can't be used independently indicate wrong boundaries
- Over-engineering: Complex solutions for simple problems indicate wrong layer
- Under-engineering: Simple solutions for complex problems indicate missing abstractions
Recovery Strategies
If you recognize these patterns, here's how to recover:
- Audit layer boundaries: Review components to ensure they're in the right layer
- Extract abstractions: Identify repeated patterns and create reusable components
- Simplify APIs: Break down components with too many props into compositions
- Refactor boundaries: Move components to correct layers based on responsibilities
- Enable composition: Provide building blocks instead of monolithic components
Warning Signs & Architecture Health
Monitor component architecture health to maintain system integrity:
Layer Health Metrics
Layer Violations: Number of components in wrong layers.
- Healthy: Zero violations—all components in correct layers
- Warning: 1-3 violations—some misclassification
- Critical: >3 violations—architecture breaking
Primitive Stability: Percentage of primitives that haven't changed APIs in 6 months.
- Healthy: >90% stable—primitives are stable
- Warning: 70-90% stable—some instability
- Critical: <70% stable—primitives not stable
Composition Metrics
Composition Rate: Percentage of components using composition vs. monolithic.
- Healthy: >80% composed—system uses composition
- Warning: 60-80% composed—some monolithic components
- Critical: <60% composed—missing composition patterns
Prop Explosion: Number of components with >15 props.
- Healthy: Zero—all components have reasonable APIs
- Warning: 1-3 components—some need refactoring
- Critical: >3 components—wrong layer or poor abstraction
Reusability Metrics
Component Duplication: Number of duplicate component implementations.
- Healthy: Zero duplicates—all teams use system components
- Warning: 1-3 duplicates—some teams reinventing
- Critical: >3 duplicates—system not meeting needs
API Consistency: Percentage of components following consistent API patterns.
- Healthy: >90% consistent—clear patterns
- Warning: 70-90% consistent—some divergence
- Critical: <70% consistent—no shared patterns
Early Warning Signs
Watch for these indicators of architecture problems:
- Increasing prop counts: Components with more props indicate wrong layer
- Component duplication: Teams creating their own versions indicates gaps
- Tight coupling: Components that can't be used independently indicate wrong boundaries
- Missing abstractions: Repeated patterns indicate missing component layers
Component Architecture Migration & Evolution
Evolving component architecture requires careful refactoring strategies:
Layer Refactoring
Move components to correct layers:
- Identify layer violations: Find components in wrong layers
- Extract primitives: Break down compound components into primitives
- Recompose: Build compounds from primitives
- Update consumers: Migrate usage to new structure
- Deprecate old: Remove old component after migration
Component Refactoring Pattern
Refactor components while maintaining compatibility:
// Phase 1: Extract primitives from compound
// Before: Compound doing too much
export const TextField = ({ label, error, ...props }) => {
return (
<div>
<label>{label}</label>
<input {...props} />
{error && <span>{error}</span>}
</div>
);
};
// After: Extract primitives
export const Label = ({ htmlFor, children }) => (
<label htmlFor={htmlFor}>{children}</label>
);
export const Input = ({ ...props }) => (
<input {...props} />
);
export const ErrorText = ({ id, children }) => (
<span id={id} role="alert">{children}</span>
);
// Phase 2: Recompose as compound
export const TextField = ({ label, error, id, ...props }) => {
const inputId = id || useId();
const errorId = `${inputId}-error`;
return (
<div>
<Label htmlFor={inputId}>{label}</Label>
<Input id={inputId} {...props} />
{error && <ErrorText id={errorId}>{error}</ErrorText>}
</div>
);
};
// Phase 3: Support both APIs during migration
// Phase 4: Deprecate old API
// Phase 5: Remove old APIProp Explosion Recovery
Refactor components with too many props:
- Extract variants: Create separate components for different variants
- Use composition: Break into smaller composable components
- Context API: Use context for shared state instead of props
- Compound pattern: Use compound components for flexible APIs
Versioning Strategy
Version components to enable safe evolution:
// Version components for breaking changes
// v1: Original API
export const ButtonV1 = ({ variant, ...props }) => {
return <button className={variantStyles[variant]} {...props} />;
};
// v2: New API (breaking change)
export const ButtonV2 = ({ appearance, ...props }) => {
return <button className={appearanceStyles[appearance]} {...props} />;
};
// Main export supports both during transition
export const Button = ({ variant, appearance, ...props }) => {
if (appearance) {
return <ButtonV2 appearance={appearance} {...props} />;
}
console.warn('Button variant prop deprecated. Use appearance instead.');
return <ButtonV1 variant={variant} {...props} />;
};
// After migration: Remove v1 supportCross-Platform Component Considerations
Component architecture must adapt to different platforms while maintaining consistency:
Platform-Native Components
Use platform-native primitives when appropriate:
- Web: HTML elements (button, input, select)
- iOS: UIKit/SwiftUI components (UIButton, UITextField)
- Android: Material Components (Button, TextInput)
- React Native: Cross-platform primitives (View, Text, TouchableOpacity)
Shared Component Contracts
Define consistent APIs across platforms:
// Shared component contract (TypeScript interface)
interface ButtonProps {
label: string;
variant: 'primary' | 'secondary' | 'ghost';
size: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
onPress: () => void;
}
// Web implementation
export const Button: React.FC<ButtonProps> = ({
label,
variant,
size,
disabled,
loading,
onPress,
}) => {
return (
<button
className={`button button--${variant} button--${size}`}
disabled={disabled || loading}
onClick={onPress}
>
{loading ? 'Loading...' : label}
</button>
);
};
// iOS implementation (Swift)
struct Button: View {
let label: String
let variant: Variant
let size: Size
let disabled: Bool
let loading: Bool
let onPress: () -> Void
var body: some View {
SwiftUI.Button(action: onPress) {
Text(loading ? "Loading..." : label)
}
.buttonStyle(ButtonStyle(variant: variant, size: size))
.disabled(disabled || loading)
}
}
// Android implementation (Kotlin)
class Button(
context: Context,
attrs: AttributeSet
) : MaterialButton(context, attrs) {
fun setVariant(variant: Variant) { /* ... */ }
fun setSize(size: Size) { /* ... */ }
fun setLoading(loading: Boolean) { /* ... */ }
}Layer Consistency Across Platforms
Maintain consistent layer architecture:
- Primitives: Platform-native elements with consistent APIs
- Compounds: Platform-specific implementations with shared patterns
- Composers: Orchestration logic can be shared (business logic)
Platform-Specific Adaptations
Adapt components for platform conventions:
- Touch targets: iOS (44pt), Android (48dp), Web (44px)
- Navigation: iOS (back button), Android (system back), Web (browser back)
- Typographic scales: Platform-specific font rendering requires adjustments
- Animation: Platform-specific animation APIs
Cross-Platform Testing
Test components across platforms:
- Visual regression: Compare screenshots across platforms
- Interaction testing: Verify interactions work correctly on each platform
- Accessibility: Test with platform-specific assistive technologies
Component Testing Strategies
Comprehensive testing ensures component quality and prevents regressions:
Unit Testing
Test component behavior in isolation:
// Component unit test
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders with label', () => {
render(<Button label="Click me" onPress={jest.fn()} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onPress when clicked', () => {
const onPress = jest.fn();
render(<Button label="Click me" onPress={onPress} />);
fireEvent.click(screen.getByText('Click me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('disables button when disabled prop is true', () => {
render(<Button label="Click me" disabled onPress={jest.fn()} />);
expect(screen.getByText('Click me')).toBeDisabled();
});
it('shows loading state', () => {
render(<Button label="Click me" loading onPress={jest.fn()} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.getByText('Click me')).toBeDisabled();
});
});Accessibility Testing
Test component accessibility:
// Accessibility testing
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
describe('Button accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<Button label="Click me" onPress={jest.fn()} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be keyboard accessible', () => {
render(<Button label="Click me" onPress={jest.fn()} />);
const button = screen.getByRole('button');
button.focus();
expect(button).toHaveFocus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(onPress).toHaveBeenCalled();
});
it('should have accessible label', () => {
render(<Button label="Click me" onPress={jest.fn()} />);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
});Visual Regression Testing
Test visual consistency across changes:
// Visual regression testing with Chromatic/Storybook
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
export default {
title: 'Components/Button',
component: Button,
};
export const Primary = {
args: {
label: 'Click me',
variant: 'primary',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(button).toHaveClass('button--primary');
},
};
// Chromatic captures screenshots automatically
// Compares against baseline imagesIntegration Testing
Test component interactions with other components:
// Integration test: Form with Button
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Form } from './Form';
import { Button } from './Button';
describe('Form integration', () => {
it('submits form when button is clicked', async () => {
const onSubmit = jest.fn();
render(
<Form onSubmit={onSubmit}>
<input name="email" />
<Button label="Submit" type="submit" />
</Form>
);
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), '[email protected]');
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
});
});
});
});Layer-Specific Testing
Test each layer appropriately:
- Primitives: Test props, rendering, basic interactions
- Compounds: Test composition, relationships between primitives
- Composers: Test state management, context propagation, complex interactions
Test Coverage Goals
Maintain appropriate test coverage:
- Primitives: 100% coverage—simple, critical building blocks
- Compounds: 90%+ coverage—important conventions
- Composers: 80%+ coverage—complex, prioritize critical paths
Real-World Architecture Case Studies
These case studies demonstrate component architecture evolution:
Case Study 1: Layer Refactoring
Challenge: A "compound" component (TextField) was doing too much—handling validation, styling, layout, and business logic.
Process:
- Identified layer violation (compound doing composer work)
- Extracted primitives (Input, Label, ErrorText)
- Recomposed as proper compound (TextField using primitives)
- Moved business logic to composer layer (FormField)
- Migrated existing usage incrementally
Results:
- TextField API simplified (5 props → 3 props)
- Primitives reusable in other contexts
- Business logic isolated and testable
- System became more flexible and maintainable
Case Study 2: Prop Explosion Recovery
Challenge: A Button component had 22 props, making it difficult to use and maintain.
Process:
- Analyzed all button usage patterns
- Identified that props were solving different problems
- Extracted variants to separate components (IconButton, LoadingButton)
- Used compound pattern for complex cases (ButtonGroup)
- Reduced core Button to 5 essential props
Results:
- Button props: 22 → 5
- Component usage became clearer
- Fewer bugs (simpler API = fewer edge cases)
- Teams adopted new API quickly
Case Study 3: Component Duplication Elimination
Challenge: 5 teams had created their own versions of the same component (Card), leading to inconsistency.
Process:
- Audited all duplicate components
- Identified common patterns and differences
- Built flexible compound component (Card with slots)
- Migrated teams to shared component
- Documented usage patterns
Results:
- Component duplication: 5 versions → 1 shared component
- Visual consistency improved across product
- Maintenance burden reduced (1 component vs. 5)
- Teams contributed improvements to shared component
Related Concepts
Reflection Questions
Reflect
When should you create a new primitive versus a compound versus a composer? What factors influence this decision?
Apply This Concept
Explain how composition becomes a governance strategy. How does layered architecture enable team autonomy while maintaining system coherence?
Apply This Concept
Describe a scenario where a component was built at the wrong layer. How would you refactor it to the correct layer?
