Skip to main content

Component Architecture

Introduction

Component architecture determines how well a frontend application scales, how easily teams collaborate, and how quickly developers can add features or fix bugs. This guide covers patterns for structuring components, managing props, enabling component composition, and facilitating communication between components.

Container vs Presentational Pattern

The container-presentational pattern separates data management from presentation logic. This separation emerged from practical challenges: components that mixed data fetching, state management, and rendering became difficult to test, reuse, and understand.

Container Components (Smart Components)

Container components handle the "how things work" concerns:

Responsibilities:

  • Fetching data from APIs or state management solutions
  • Managing local component state (loading, errors, filters)
  • Handling side effects (data mutations, navigation)
  • Orchestrating interactions between multiple presentational components
  • Connecting to context providers or global state

Characteristics:

  • Often thin on rendering logic - mostly data handling and coordination
  • Know about application architecture (API structure, routing, state management)
  • Can be harder to reuse across different applications
  • May not have their own styling

Container Component Example:

A container component handles data fetching, manages loading/error states, and delegates rendering to presentational components. The specific implementation varies by framework, but the responsibilities remain the same.

// PaymentListContainer (conceptual - see framework guides for specific syntax)

function PaymentListContainer() {
// Fetch data using framework-specific mechanism
const { payments, isLoading, error, refetch } = fetchPayments({
sortBy: 'date',
order: 'desc'
});

// Container handles all data states
if (isLoading) {
return <LoadingSpinner message="Loading payments..." />;
}

if (error) {
return <ErrorMessage error={error} onRetry={refetch} />;
}

if (payments.length === 0) {
return <EmptyState message="No payments found." />;
}

// Delegate rendering to presentational component
return <PaymentList payments={payments} onRefresh={refetch} />;
}

This container knows about the data layer (how to fetch payments), manages state (loading, error, data), and coordinates between the data source and the presentational component.

Presentational Components (Dumb Components)

Presentational components focus on "how things look":

Responsibilities:

  • Rendering UI based on props
  • Handling user interactions through callback props
  • Managing only UI-related state (dropdown open/closed, selected tab)
  • Providing consistent styling and layout

Characteristics:

  • Highly reusable across different contexts
  • Easy to test - just pass different props
  • No dependencies on application architecture
  • Often used in design systems and component libraries

Presentational Component Example:

A presentational component receives data through its interface (props/inputs) and renders UI. It has no dependencies on data fetching or application state.

// PaymentList (conceptual - framework syntax varies)

interface PaymentListProps {
payments: Payment[]; // Data passed in
onRefresh?: () => void; // Optional callback
}

function PaymentList({ payments, onRefresh }) {
return (
<div className="payment-list">
<div className="payment-list__header">
<h2>Recent Payments</h2>
{onRefresh && (
<button onClick={onRefresh}>Refresh</button>
)}
</div>

<div className="payment-list__grid">
{payments.map(payment => (
<PaymentCard key={payment.id} payment={payment} />
))}
</div>
</div>
);
}

This component is completely reusable - it can display any array of payments from any source. It doesn't know or care where the data came from or how it was fetched.

Pattern Benefits

Why This Separation Matters:

  1. Testing: Presentational components are pure functions of props - easy to test without mocking APIs or state management. Container components can be tested separately for data handling logic.

  2. Reusability: The PaymentList presentational component can display payments from different sources: API data, local storage, search results, or static examples in documentation.

  3. Designer Collaboration: Designers can work with presentational components in isolation using tools like Storybook, seeing all visual states without backend dependencies.

  4. Parallel Development: Frontend developers can build presentational components with mock data while backend developers finalize APIs.

Component Composition

Composition is the process of building complex UIs from simpler components. It's one of the most powerful patterns in frontend development, enabling flexibility without inheritance or complex component hierarchies.

Composition vs Configuration

Two approaches exist for making components flexible: composition and configuration.

Configuration uses props to control behavior:

// Configuration approach - okay for simple cases
<Card
showHeader={true}
headerText="Payment Details"
showFooter={true}
footerText="View More"
variant="elevated"
padding="large"
/>

This becomes unwieldy as configuration needs grow. Adding new variations requires new props, increasing complexity.

Composition uses children and slots:

// Composition approach - more flexible
<Card variant="elevated" padding="large">
<CardHeader>
<h2>Payment Details</h2>
<StatusBadge status="completed" />
</CardHeader>

<CardBody>
<PaymentDetails payment={payment} />
</CardBody>

<CardFooter>
<Button>View More</Button>
<Button variant="secondary">Download</Button>
</CardFooter>
</Card>

Composition allows unlimited variations without modifying the Card component. Want to add a new button? Just add it. Want to change the header layout? Modify the children.

Children/Content Projection Pattern

Components accept arbitrary content from their consumers, controlling where and how that content is rendered. This is called "children" in React, "content projection" in Angular, and "slots" in Vue.

Core Concept:

A component receives content from its parent and places it within its own template structure. The component controls the layout, styling, and behavior, while the consumer provides the actual content.

Pattern Benefits:

  • Maximum flexibility: Consumers can pass any content type
  • Simple API: Single content area with no configuration needed
  • Composition: Build complex components by nesting
  • Encapsulation: Component controls its own structure and styling

Example Structure:

Card Component (manages styling and layout)
└── Consumer-provided content (any elements)

Usage Pattern:

<Card variant="elevated" padding="large">
<header>
<h2>Account Balance</h2>
</header>
<div>
<Currency amount={1234.56} currency="USD" />
</div>
<footer>
<Button>Transfer</Button>
<Button>Pay</Button>
</footer>
</Card>

The Card component wraps the provided content with consistent styling and layout, but doesn't dictate what that content must be.

Compound Components Pattern

Compound components work together as a family, sharing state implicitly through internal mechanisms (context in React, content projection in Angular). This pattern creates component APIs that are flexible and declarative.

Core Concept:

Multiple components work together to form a complete UI element. The parent component manages shared state, and child components access that state without explicit prop passing. This enables flexible composition where consumers can arrange sub-components however they need.

Pattern Benefits:

  • Flexible API: Users arrange sub-components in any order or structure
  • Implicit state sharing: Child components access parent state without prop drilling
  • Semantic markup: Component relationships are clear from the structure
  • Encapsulation: Implementation details hidden from consumers
  • Extensibility: Easy to add new sub-components without changing the parent

Common Use Cases:

  • Tabs (tab buttons and panels share active state)
  • Accordions (panels share expanded/collapsed state)
  • Dropdowns (trigger and menu share open/closed state)
  • Wizards (steps share current step and navigation state)

Example Structure (Framework-Agnostic):

Tabs (manages active tab state)
├── TabList (container for tab buttons)
│ ├── Tab (individual tab button, accesses active state)
│ ├── Tab
│ └── Tab
└── TabPanels
├── TabPanel (content panel, shows/hides based on active state)
├── TabPanel
└── TabPanel

Usage Pattern:

<Tabs defaultTab="details">
<TabList>
<Tab value="details">Details</Tab>
<Tab value="history">History</Tab>
<Tab value="documents">Documents</Tab>
</TabList>

<TabPanel value="details">
[Details content]
</TabPanel>

<TabPanel value="history">
[History content]
</TabPanel>

<TabPanel value="documents">
[Documents content]
</TabPanel>
</Tabs>

Framework Implementations:

Logic Extraction Patterns

Extracting reusable logic from components improves code reuse and testability. Different frameworks provide different mechanisms:

Approaches by Framework:

  • React: Custom hooks extract stateful logic into reusable functions. See React Component Patterns for hook-based logic extraction and the render props pattern.
  • Angular: Services with dependency injection provide shared logic. See Angular Dependency Injection for service-based logic extraction.

Universal Principle:

Separate component logic (data fetching, state management, business rules) from presentation logic (rendering, styling, user interaction). This separation enables:

  • Reusability: Logic can be used by multiple components
  • Testability: Logic can be tested independently of UI
  • Maintainability: Changes to logic don't affect presentation and vice versa
  • Parallel Development: Logic and UI can be developed separately

Slot Pattern (Named Content Areas)

Slots provide named placeholders for content, allowing components to accept different content for different structural areas. This pattern is common across frameworks (Vue's slots, Angular's ng-content with select, React's props/children).

Core Concept:

A component defines multiple named content areas (slots). Consumers provide content for each slot, and the component controls the layout and structure around those areas.

Pattern Benefits:

  • Flexible layouts: Same component structure with different content
  • Clear API: Named slots make it obvious where content goes
  • Consistent structure: Component enforces layout; consumer provides content
  • Reusability: Same structural component works for many use cases

Common Use Cases:

  • Modals (header, body, footer areas)
  • Cards (header, content, actions areas)
  • Layouts (sidebar, main content, toolbar areas)
  • Panels (title, content, controls areas)

Example Structure (Framework-Agnostic):

Modal Component
├── Header Slot (optional)
│ └── [Consumer-provided header content]
├── Body Slot (required)
│ └── [Consumer-provided main content]
└── Footer Slot (optional)
└── [Consumer-provided action buttons]

Usage Pattern:

<Modal open={isOpen} onClose={handleClose}>
<slot name="header">
<h2>Confirm Payment</h2>
</slot>

<slot name="body">
<p>Are you sure you want to send $100?</p>
<PaymentDetails />
</slot>

<slot name="footer">
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleConfirm}>Confirm</Button>
</slot>
</Modal>

Framework Implementations:

Props Patterns

Props Interface Design

Well-designed component interfaces make components easy to use correctly and hard to use incorrectly.

Principle: Required vs Optional

Mark inputs required when the component cannot function without them. Use optional inputs with sensible defaults for configuration:

Button Component Interface:
Required:
- content: Any // Button needs content to display
- onClick: Function // Button needs an action

Optional (with defaults):
- variant: 'primary' | 'secondary' | 'danger' = 'primary'
- size: 'small' | 'medium' | 'large' = 'medium'
- disabled: boolean = false
- loading: boolean = false

Usage:
<Button onClick={handleClick} variant="danger" loading>
Submit
</Button>

Principle: Boolean Props

Boolean props work well for simple yes/no configurations:

<Button loading disabled>Submit</Button>

For related booleans that are mutually exclusive, consider enums instead:

// Bad - mutually exclusive booleans
<Button primary secondary danger>Submit</Button>

// Good - enum prop
<Button variant="danger">Submit</Button>

Principle: Callback Props

Prefix callback props with on and use descriptive names:

PaymentForm Component Interface:
Callbacks:
- onSubmit: (payment) => void // Good - action is clear
- onCancel: () => void // Good - action is clear
- onValidationError: (errors) => void // Good - specific
- onChange: (field, value) => void // Good - change handler

Framework Implementations:

Props Spreading and Rest

Props spreading forwards additional props to child elements, useful for wrapper components that need to accept native element attributes.

Pattern concept:

TextInput Component:
Custom props:
- label: string (required)
- error: string (optional)

Inherited from native input:
- type, placeholder, required, min, max, step, etc.

Implementation:
- Extract custom props (label, error)
- Spread remaining props to native input element

Usage example:

// Custom component supports all native input attributes
<TextInput
label="Amount"
error={validationError}
type="number"
placeholder="Enter amount"
required
min={0}
step={0.01}
/>

This pattern enables custom components that behave like native elements while adding functionality (labels, error displays, styling).

Framework Implementations:

  • React: Use ...rest spread syntax with TypeScript extending native element types
  • Angular: Use @Input() with attribute binding, or host bindings

Discriminated Union Props

Discriminated unions create component interfaces where allowed props change based on a type/variant field. This enables type-safe components that behave differently based on configuration.

Pattern concept:

Button Component (discriminated by 'variant'):

When variant = 'link':
- href: string (required)
- target: string (optional)
- rel: string (optional)
- onClick: NOT ALLOWED

When variant = 'button':
- onClick: function (required)
- type: 'button' | 'submit' | 'reset' (optional)
- href: NOT ALLOWED

Benefits:

  • Type system prevents invalid combinations (link buttons can't have onClick)
  • Clear API contract for each variant
  • Better IDE autocomplete based on selected variant

Usage:

// Valid - link variant requires href
<Button variant="link" href="/dashboard">Go to Dashboard</Button>

// Valid - button variant requires onClick
<Button variant="button" onClick={handleSubmit}>Submit</Button>

// Invalid - type system error: onClick not allowed for link variant
<Button variant="link" href="/home" onClick={handleClick}>Home</Button>

Framework Implementations:

Component Communication

Props Down, Events Up

The fundamental data flow pattern: data flows down through props/inputs, events flow up through callbacks/outputs.

This unidirectional flow makes data changes predictable and debuggable. You can trace data from parent to child and events from child to parent.

Pattern example:

PaymentPage (parent):
State:
- selectedPayment = null
- isModalOpen = false

Handler: handleSelectPayment(payment)
- selectedPayment = payment
- isModalOpen = true

Handler: handleCloseModal()
- isModalOpen = false
- selectedPayment = null

Render:
<PaymentList
payments={payments} // Data flows down
onSelectPayment={handleSelectPayment} // Handler flows down
/>

if selectedPayment:
<PaymentDetailsModal
payment={selectedPayment} // Data flows down
isOpen={isModalOpen} // Data flows down
onClose={handleCloseModal} // Handler flows down
/>

When user selects a payment in the list, onSelectPayment callback fires up to parent, which updates state. New state flows down to modal component.

Shared State Without Prop Drilling

When multiple components at different tree levels need access to the same state, prop drilling (passing props through intermediate components) becomes cumbersome. Frameworks provide different mechanisms to share state without explicitly passing it through every level:

Mechanisms by Framework:

  • React: Context API provides implicit state sharing. See React State Management for Context implementation patterns.
  • Angular: Services with dependency injection provide shared state. See Angular Dependency Injection for service-based state sharing.
  • Vue: Provide/Inject API and Pinia for state management.

When to Use:

Use shared state mechanisms for truly cross-cutting concerns:

  • Authentication state (current user, permissions)
  • Theme/appearance settings (dark mode, locale)
  • Feature flags and configuration
  • Global UI state (notifications, modals)

When NOT to Use:

Don't use shared state to avoid passing props through 2-3 levels. Explicit prop passing makes data flow visible and components more portable. Overusing global state makes components harder to test and reuse.

General Pattern:

Application Root
└── State Provider (manages authentication, theme, etc.)
├── Page Component (accesses shared state)
│ └── Child Component (accesses shared state directly)
└── Another Page (accesses same shared state)

Any component within the provider can access the shared state without intermediate components needing to pass it down.

Component Size and Complexity

When to Split Components

Split components when:

  1. Component exceeds ~200-300 lines: Large components are hard to understand and test
  2. Multiple responsibilities: Component does too many unrelated things
  3. Reusability opportunity: Part of the component would be useful elsewhere
  4. Testing complexity: Component is hard to test due to size or complexity

Before - large component doing too much:

PaymentPage (500 lines):
- Data fetching logic
- Multiple form state management
- Complex validation rules
- List rendering
- Modal management
- Error handling
- Loading states

After - split into focused components:

PaymentPage:
<PaymentFilters /> // Filters and search (isolated concern)
<PaymentListContainer /> // Data fetching + list (container pattern)
<PaymentFormModal /> // Form in modal (isolated concern)

Each component has a single responsibility and can be tested independently.

Component Depth

Limit component nesting depth. Deep nesting makes code hard to follow and suggests missing abstractions:

Too deep - hard to follow:

<Page>
<Container>
<Section>
<Grid>
<Row>
<Column>
<Card>
<CardHeader>
<Title>...</Title>
</CardHeader>
</Card>
</Column>
</Row>
</Grid>
</Section>
</Container>
</Page>

Better - introduce intermediate components:

<PaymentDetailsPage>
<PaymentCard payment={payment} /> // Encapsulates internal structure
</PaymentDetailsPage>

The PaymentCard component encapsulates its internal structure. Consumers don't need to know about the grid, columns, or card sub-components.

Summary

Effective component architecture separates data management (containers) from presentation (presentational components). Composition patterns enable flexible, reusable components without complex configuration. Component interfaces should be well-typed with clear required vs optional boundaries. Communication follows unidirectional data flow: props down, events up. Shared state mechanisms (Context, Services) solve cross-cutting concerns without prop drilling. Extract reusable logic into dedicated abstractions (hooks, services). Keep components focused, reasonably sized, and shallowly nested for maximum maintainability.