Skip to main content

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.

Darian Rosebrook
Staff Design Technologist, Design Systems Architect
Expertise:Component Architecture, React, Design Systems

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 anywhere

Compounds: 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 compound

Composers: 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 reusable

API 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 state

Composition 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 layout

Step 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 composition

2. 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 understand

3. 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 usage

4. 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 what

5. 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 abstraction

6. 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 codebase

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

  1. Identify layer violations: Find components in wrong layers
  2. Extract primitives: Break down compound components into primitives
  3. Recompose: Build compounds from primitives
  4. Update consumers: Migrate usage to new structure
  5. 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 API

Prop 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 support

Cross-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 images

Integration 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:

  1. Identified layer violation (compound doing composer work)
  2. Extracted primitives (Input, Label, ErrorText)
  3. Recomposed as proper compound (TextField using primitives)
  4. Moved business logic to composer layer (FormField)
  5. 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:

  1. Analyzed all button usage patterns
  2. Identified that props were solving different problems
  3. Extracted variants to separate components (IconButton, LoadingButton)
  4. Used compound pattern for complex cases (ButtonGroup)
  5. 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:

  1. Audited all duplicate components
  2. Identified common patterns and differences
  3. Built flexible compound component (Card with slots)
  4. Migrated teams to shared component
  5. 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