Component Composition Techniques

·16 min read·Frontend Developmentintermediate

Why assembling UI from smaller, independent parts matters more than ever in modern web development.

A conceptual arrangement of modular UI components like buttons, cards, and input fields, demonstrating how they fit together to form larger interfaces

I spent the first few years of my career writing components that tried to do everything. A form component that handled its own state, validation, styling, and even API calls. It felt clever at first. Then the requirements changed, and I was copying and pasting entire files just to add a spinner or swap a label. The moment I realized I was fighting my own code was the moment I started paying attention to component composition. It is not a new idea, but in 2025 it feels like the primary skill that separates a frustrating codebase from one that flows.

Component composition is the practice of building UI by combining small, focused parts rather than building monolithic blocks. It is the difference between a tool that does one thing well and a Swiss Army knife that pinches your fingers. In this post, I will explain why this approach matters now more than ever, show real patterns that work in production, and share where it can backfire if applied blindly. I will ground everything in practical code examples so you can see how the ideas translate into your day-to-day work.

Context: Where composition fits in modern frontend work

Frontend development today is dominated by component-driven frameworks like React, Vue, Svelte, and Solid. These libraries are designed around the idea of reusable UI pieces, but they leave composition strategy to the developer. That flexibility is both a blessing and a trap. Teams ship faster when they establish conventions for how components are composed, especially in large projects with multiple contributors.

Composition sits at the core of several mainstream trends:

  • Design systems that provide atomic building blocks.
  • Micro frontends that combine independent pieces at the edge.
  • Server components and streaming UI where boundaries determine what hydrates when.

In practice, most teams use composition to reduce duplication, isolate concerns, and make incremental updates safer. A well-composed codebase lets you change a button variant across an entire app by editing one component, or swap a data table with a virtualized version without rewriting the consuming page. Compared to inheritance or deep prop drilling, composition encourages predictable data flow and easier testing. It is not the only approach, but it is the most flexible one when product requirements evolve quickly.

Core concepts: What component composition means in real code

At a high level, composition means one component delegates parts of its responsibility to children. Instead of a component controlling every detail, it exposes slots or accepts render props so the caller decides what gets rendered in certain regions.

In React, this is often done with the children prop or render props. In Vue, scoped slots and the composition API make this natural. Svelte uses slots and snippets. Solid follows a similar children pattern. The key idea is to pass behavior, not just configuration.

Children as a composition surface

Using children is the simplest form of composition. It lets you wrap content without needing to know exactly what it is.

// Card.jsx
export function Card({ children, elevation = 1 }) {
  const shadow = elevation === 2 ? 'shadow-lg' : 'shadow-md';
  return (
    <div className={`bg-white rounded-lg p-4 ${shadow}`}>
      {children}
    </div>
  );
}

// Usage.jsx
import { Card } from './Card';

export function Usage() {
  return (
    <Card elevation={2}>
      <h2>Profile</h2>
      <p>Name: Ada Lovelace</p>
    </Card>
  );
}

This pattern is powerful because the Card component does not need to know about headers, paragraphs, or even buttons. It focuses on layout and elevation. The consumer assembles the content.

Render props for dynamic behavior

Sometimes you need a parent to control what a child renders, especially when the child manages layout or logic. Render props provide a function as a prop that the child calls with its internal data.

// List.jsx
export function List({ items, renderItem }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage.jsx
import { List } from './List';

export function Usage() {
  const users = [
    { id: 1, name: 'Grace Hopper' },
    { id: 2, name: 'Linus Torvalds' },
  ];

  return (
    <List
      items={users}
      renderItem={(user) => (
        <div className="flex items-center justify-between">
          <span>{user.name}</span>
          <button onClick={() => console.log(user)}>View</button>
        </div>
      )}
    />
  );
}

The List component handles iteration and layout, while the renderItem prop determines the look and feel of each row. This eliminates the need for a prop like showActionButton or rowClassName, which bloat APIs over time.

Slots in Vue for flexible insertion

In Vue, slots allow a parent to insert content into a child’s predefined areas. Scoped slots go further by passing data from the child back to the parent.

<!-- DataTable.vue -->
<template>
  <table class="data-table">
    <thead>
      <tr>
        <slot name="headers" />
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in rows" :key="row.id">
        <slot name="row" :row="row" />
      </tr>
    </tbody>
  </table>
</template>

<script setup>
defineProps({
  rows: Array
});
</script>

<!-- Usage.vue -->
<template>
  <DataTable :rows="users">
    <template #headers>
      <th>Name</th>
      <th>Email</th>
      <th>Actions</th>
    </template>

    <template #row="{ row }">
      <td>{{ row.name }}</td>
      <td>{{ row.email }}</td>
      <td><button @click="edit(row)">Edit</button></td>
    </template>
  </DataTable>
</template>

<script setup>
import { ref } from 'vue';
import DataTable from './DataTable.vue';

const users = ref([
  { id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
  { id: 2, name: 'Alan Turing', email: 'alan@example.com' },
]);

function edit(user) {
  console.log('Edit', user);
}
</script>

This pattern allows the same table component to render different cell layouts without configuration flags. It is widely used in design systems because it scales well with many product surfaces.

Snippets in Svelte for scoped templates

Svelte 5 introduced snippets, which provide a robust way to inject dynamic templates into components. It is similar to slots but with more flexibility for inline logic.

<!-- List.svelte -->
<script>
  let { items, children } = $props();
</script>

<ul>
  {#each items as item}
    <li>
      {@render children(item)}
    </li>
  {/each}
</ul>

<!-- Usage.svelte -->
<script>
  import List from './List.svelte';

  const items = [
    { id: 1, name: 'Katherine Johnson' },
    { id: 2, name: 'Tim Berners-Lee' },
  ];
</script>

<List {items}>
  {#snippet children(item)}
    <div class="row">
      <strong>{item.name}</strong>
      <button onclick={() => console.log(item)}>Details</button>
    </div>
  {/snippet}
</List>

Snippets make it easy to pass templates without losing reactivity or scope. This keeps components composable while avoiding prop explosion.

Composition with React hooks for logic reuse

Composition is not just about UI. Logic can be composed too. React hooks are a common pattern to extract stateful logic from components.

// useSelection.js
import { useState } from 'react';

export function useSelection(items) {
  const [selected, setSelected] = useState(new Set());

  const toggle = (id) => {
    const next = new Set(selected);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    setSelected(next);
  };

  const clear = () => setSelected(new Set());

  return {
    selected,
    toggle,
    clear,
    isSelected: (id) => selected.has(id),
  };
}

// UserTable.jsx
import { useSelection } from './useSelection';

export function UserTable({ users }) {
  const { selected, toggle, clear, isSelected } = useSelection(users.map(u => u.id));

  return (
    <div>
      <div>
        <button onClick={clear}>Clear</button>
        <span>Selected: {selected.size}</span>
      </div>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>
                <input
                  type="checkbox"
                  checked={isSelected(user.id)}
                  onChange={() => toggle(user.id)}
                />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

This isolates selection logic so it can be tested independently and reused across different UI components. The UI then composes behavior rather than containing it.

Real-world project structure

Composition works best when the project structure reflects the separation of concerns. Below is a simplified layout for a design system driven app.

src/
  components/
    ui/
      Button.jsx
      Card.jsx
      Modal.jsx
    layout/
      Grid.jsx
      Stack.jsx
    features/
      UserTable.jsx
      ProfileCard.jsx
  lib/
    useSelection.js
    useDebounce.js
  pages/
    Users.jsx
    Profile.jsx
  App.jsx
  • ui/ contains primitive components with minimal logic.
  • layout/ contains structural components that manage spacing and responsiveness.
  • features/ composes primitives and hooks into domain-specific components.
  • pages/ assemble features and handle data fetching.

A clear structure helps teams discover components and encourages reuse. It also guides where composition should happen. Pages compose features; features compose UI primitives and logic hooks. This layering is more important than any single composition pattern.

Practical patterns and decisions

The following patterns illustrate how composition handles common scenarios in real products.

Conditional composition vs prop drilling

Avoid adding many optional props to a component. Instead, compose behavior through children.

// Instead of this
function Card({ title, subtitle, footer, border, shadow, padding, ...props }) {
  /* huge prop list and conditional logic */
}

// Compose like this
<Card border="solid" shadow="lg">
  <CardHeader>
    <h2>Settings</h2>
    <p>Manage your profile</p>
  </CardHeader>
  <CardBody>...</CardBody>
  <CardFooter>
    <button>Save</button>
  </CardFooter>
</Card>

Compound components for complex interactions

Compound components allow a parent group to coordinate shared state without exposing many props. A common example is a Tabs component.

// Tabs.jsx
import { createContext, useContext, useState } from 'react';

const TabsContext = createContext();

export function Tabs({ children, defaultIndex = 0 }) {
  const [activeIndex, setActiveIndex] = useState(defaultIndex);
  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

export function TabList({ children }) {
  return <div className="tab-list">{children}</div>;
}

export function Tab({ index, children }) {
  const { activeIndex, setActiveIndex } = useContext(TabsContext);
  return (
    <button
      className={activeIndex === index ? 'active' : ''}
      onClick={() => setActiveIndex(index)}
    >
      {children}
    </button>
  );
}

export function TabPanel({ index, children }) {
  const { activeIndex } = useContext(TabsContext);
  return activeIndex === index ? <div className="tab-panel">{children}</div> : null;
}

// Usage.jsx
import { Tabs, TabList, Tab, TabPanel } from './Tabs';

export function Usage() {
  return (
    <Tabs defaultIndex={0}>
      <TabList>
        <Tab index={0}>Profile</Tab>
        <Tab index={1}>Security</Tab>
      </TabList>
      <TabPanel index={0}>Profile settings form</TabPanel>
      <TabPanel index={1}>Password and 2FA options</TabPanel>
    </Tabs>
  );
}

This pattern encapsulates the coordination logic while giving consumers control over layout and content.

Passing context through the tree

When a deeply nested component needs data or actions, context avoids prop drilling.

// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

// Button.jsx
import { useTheme } from './ThemeContext';

export function Button({ children, ...props }) {
  const { theme } = useTheme();
  const className = theme === 'dark' ? 'btn-dark' : 'btn-light';
  return (
    <button className={className} {...props}>
      {children}
    </button>
  );
}

// Usage.jsx
import { ThemeProvider } from './ThemeContext';
import { Button } from './Button';

export function App() {
  return (
    <ThemeProvider>
      <div className="p-4">
        <Button onClick={() => alert('Clicked')}>Save</Button>
      </div>
    </ThemeProvider>
  );
}

Context is best for cross-cutting concerns like theming, localization, or auth state. For feature-specific state, prefer passing data explicitly to keep boundaries clear.

Composition in Vue with provide and inject

Vue provides a similar mechanism through provide and inject, which is handy for plugin systems or form libraries.

<!-- FormProvider.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <slot />
  </form>
</template>

<script setup>
import { provide, ref, readonly } from 'vue';

const values = ref({});

function setField(name, value) {
  values.value[name] = value;
}

function handleSubmit() {
  console.log('Submitting', values.value);
}

provide('form', {
  values: readonly(values),
  setField,
});
</script>

<!-- TextInput.vue -->
<template>
  <input v-model="model" @input="update" />
</template>

<script setup>
import { inject, ref } from 'vue';

const props = defineProps(['name']);
const form = inject('form');
const model = ref('');

function update() {
  form.setField(props.name, model.value);
}
</script>

<!-- Usage.vue -->
<template>
  <FormProvider>
    <TextInput name="email" />
    <TextInput name="password" />
    <button type="submit">Login</button>
  </FormProvider>
</template>

<script setup>
import FormProvider from './FormProvider.vue';
import TextInput from './TextInput.vue';
</script>

This composes form behavior without a monolithic Form component.

Error boundaries and fallback composition

When composing UI, consider failure modes. In React, error boundaries let you isolate UI crashes.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error('UI error', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>Something went wrong.</div>;
    }
    return this.props.children;
  }
}

// Usage.jsx
<ErrorBoundary fallback={<div>Profile failed to load.</div>}>
  <ProfileCard />
</ErrorBoundary>

Fallback composition is useful for loading states, empty states, and error recovery.

Evaluating tradeoffs

Composition is not a silver bullet. It introduces abstraction layers that can overcomplicate small projects. Below are strengths, weaknesses, and guidance based on experience.

Strengths:

  • Flexibility: Components adapt to new requirements without rewriting internals.
  • Testability: Small units with clear contracts are easier to test.
  • Maintainability: Changes are localized, reducing the risk of regression.

Weaknesses:

  • Boilerplate: Over-composing tiny components can clutter the codebase.
  • Indirection: Deep nesting can make data flow harder to trace.
  • API surface: Poorly designed slot interfaces can be confusing for consumers.

When to use:

  • Teams building shared libraries or design systems.
  • Apps with evolving UI requirements and many product surfaces.
  • Codebases needing better separation of concerns to speed up onboarding.

When to avoid:

  • Prototypes where velocity beats structure.
  • Very simple apps with a single use case and little reusability.
  • Situations where performance is critical and indirection adds overhead.

In performance-sensitive contexts, prefer direct composition and avoid unnecessary context providers or render props that trigger frequent re-renders. Profiling tools like React DevTools Profiler or Vue DevTools can reveal the cost of indirection.

Personal experience and common mistakes

I learned composition the hard way, by pushing logic down into shared components until they were brittle. One project had a “SuperTable” with 27 props and no composition surface. Adding a new column type required changes in three repositories because the table also embedded business logic for sorting and pagination. Refactoring to a compound component with scoped slots reduced the props to eight and removed two thousand lines of code.

Common mistakes I see and have made:

  • Adding too many optional props instead of passing children or slots.
  • Using context for feature-level state that would be clearer as props.
  • Creating components that are only used once and have no clear contract.
  • Ignoring error boundaries and leaving apps vulnerable to isolated UI crashes.

A practice that helped was documenting component contracts. For each component, we wrote a short README with the intended use, composition examples, and a list of props or slots. This is not about heavy documentation but about shared understanding. A few lines per component can prevent misuse.

Getting started: Workflow and mental model

Setting up a composable project is less about tooling and more about decisions. The following workflow keeps composition focused and practical.

Step 1: Define primitive components

Create a small set of building blocks that are style-driven and minimal.

src/components/ui/
  Button.jsx
  Input.jsx
  Card.jsx
  Modal.jsx

These components should accept limited props and rarely contain domain logic.

Step 2: Define layout components

Handle structure and spacing.

src/components/layout/
  Stack.jsx     // vertical spacing
  Grid.jsx      // responsive grid
  Container.jsx // width constraints

Layout components wrap primitives and help enforce design tokens.

Step 3: Compose feature components

Build domain-specific components using primitives, layout, and shared hooks.

src/components/features/
  UserTable.jsx
  ProfileCard.jsx
  SettingsForm.jsx

Keep feature components free of global context unless absolutely necessary.

Step 4: Assemble pages

Fetch data at the page level and compose features.

src/pages/
  Users.jsx
  Profile.jsx

Pages coordinate routing and data dependencies. Avoid deep prop drilling by using context sparingly and carefully.

Step 5: Review and refine contracts

Before adding a new prop, ask: can this be composed instead?

  • If the change is layout-related, pass children or use slots.
  • If the change is behavior-related, expose a render prop or snippet.
  • If the change is stateful logic, extract a hook or composable function.

Example project setup in React

project/
  src/
    components/
      ui/
        Button.jsx
        Card.jsx
      layout/
        Stack.jsx
      features/
        UserTable.jsx
    lib/
      useSelection.js
    pages/
      Users.jsx
    App.jsx
  package.json
{
  "name": "composition-demo",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4",
    "vite": "^5"
  }
}
// App.jsx
import { Users } from './pages/Users';

export default function App() {
  return <Users />;
}
// pages/Users.jsx
import { UserTable } from '../components/features/UserTable';
import { Stack } from '../components/layout/Stack';

export function Users() {
  const users = [
    { id: 1, name: 'Grace Hopper', email: 'grace@navy.mil' },
    { id: 2, name: 'Donald Knuth', email: 'donald@stanford.edu' },
  ];

  return (
    <Stack spacing={4}>
      <h1>Users</h1>
      <UserTable users={users} />
    </Stack>
  );
}

This structure reflects composition at the architectural level, not just inside components.

Standout features and developer experience

What makes composition techniques stand out is how they improve developer experience and maintainability.

  • Clearer responsibilities: Each component has a single job.
  • Predictable changes: Layout updates don’t break business logic.
  • Easier testing: You test small units and their contracts rather than monoliths.
  • Better design systems: Primitives compose into complex UI without rewriting styles.

Developer experience benefits include faster onboarding, fewer merge conflicts, and confidence when refactoring. In practice, teams using well-defined composition patterns can ship features in parallel with fewer integration issues. While frameworks differ in syntax, the mental model is consistent across React, Vue, Svelte, and Solid.

Free learning resources

These resources are practical, maintained, and directly applicable to real projects.

Summary: Who should use this and who might skip it

Component composition is a strong default for teams building shared libraries, design systems, or products with evolving UI needs. It helps isolate concerns, reduces duplication, and supports parallel development. If your app is likely to grow beyond a few screens or involves multiple product surfaces, leaning into composition will pay off.

You might skip or defer deep composition in small prototypes, single-use dashboards, or performance-critical paths where indirection introduces measurable overhead. In those cases, start simple and refactor toward composition when duplication appears.

The most grounded takeaway is this: composition is not about writing clever abstractions. It is about making it easy to change your UI without breaking unrelated parts of the app. When done well, it turns your codebase into a set of reliable building blocks that fit together cleanly, and that is a foundation worth investing in.