Skip to content

Component Composition

Real interfaces are built from many components working together. This lesson covers the common patterns for composing Lit components: property/event data flow and slot-based structural composition.

Two Ways to Compose Components

There are two complementary composition patterns in Lit: property/event composition, where a parent passes data down via properties and listens for events dispatched back up; and slot-based composition, where a parent's markup structure is projected into a child component's template, letting the child control layout while the parent controls content.

Most real components combine both — a <data-table> might accept a .rows=${data} property for its data while also exposing named slots for custom column headers or action buttons.

class TodoApp extends LitElement {
  @state() private todos = [{ id: 1, text: 'Learn Lit', done: false }];

  render() {
    return html`
      <todo-list
        .items=${this.todos}
        @todo-toggled=${this.#onToggle}
      ></todo-list>
    `;
  }

  #onToggle(event) {
    const { id } = event.detail;
    this.todos = this.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
  }
}

TodoApp owns the actual data and passes it down via a property; todo-list never mutates todos directly, only dispatches an event describing what happened.

Compound Component Pattern

<tab-group>
  <tab-panel label="One">Content A</tab-panel>
  <tab-panel label="Two">Content B</tab-panel>
</tab-group>
  • A 'compound component' is a parent element (<tab-group>) that coordinates a set of related child elements (<tab-panel>) slotted inside it.
  • The parent typically discovers its children via slotchange and assignedElements(), or via a shared context/controller (covered in the controllers lessons).
  • Children can expose simple properties (like label) that the parent reads to build its own UI (like generated tab buttons).
  • This pattern mirrors how native <select>/<option> and <table>/<tr>/<td> work in HTML itself.

Composition Patterns Cheat Sheet

Choosing a composition approach for a given relationship.

Relationship Recommended Pattern
Parent owns data, child displays it Property down, event up
Parent provides structural markup, child provides layout Slots
Group of related, coordinated siblings Compound component with slotted children
Deeply nested components sharing state A reactive controller or shared context (see Controllers lessons)

Avoiding Excessive Prop Drilling

Passing data down through many layers of intermediate components that don't themselves use it (only forwarding it further down) is called 'prop drilling', and it makes components harder to reuse independently. For deeply nested sharing needs, a reactive controller with a shared, injected state object (or the Context community protocol) is usually a better fit than threading properties through every intermediate layer.

Building a Simple Compound Component

A tab group is a classic compound component: the parent renders tab buttons based on its slotted <tab-panel> children's label properties, and controls which panel is visible.

class TabGroup extends LitElement {
  @state() private panels = [];
  @state() private activeIndex = 0;

  #onSlotChange(event) {
    this.panels = event.target.assignedElements();
    this.panels.forEach((panel, i) => { panel.hidden = i !== this.activeIndex; });
  }

  #selectTab(index) {
    this.activeIndex = index;
    this.panels.forEach((panel, i) => { panel.hidden = i !== index; });
  }

  render() {
    return html`
      <div class="tabs">
        ${this.panels.map((panel, i) => html`
          <button @click=${() => this.#selectTab(i)}>${panel.label}</button>
        `)}
      </div>
      <slot @slotchange=${this.#onSlotChange}></slot>
    `;
  }
}

The parent reads each slotted <tab-panel>'s label property directly and toggles its hidden attribute to control visibility.

Common Mistakes

  • Threading a property through several intermediate components that don't use it themselves, instead of using a shared controller/context.
  • Building a compound component that reaches into a child's Shadow DOM internals directly instead of using its public property/attribute API.
  • Forgetting to re-run slot discovery logic (assignedElements()) when slotted children are added or removed later.
  • Duplicating the same data-fetching or state logic across sibling components instead of sharing it through a common ancestor or controller.

Key Takeaways

  • Property/event composition (data down, events up) and slot-based composition are the two core patterns.
  • Most real components combine both, using slots for structure and properties/events for data.
  • Compound components coordinate slotted children by reading their public properties, similar to native <select>/<option>.
  • For deeply nested sharing needs, prefer a shared controller or context over prop drilling through many layers.

Pro Tip

Before building a compound component from scratch, check whether the community's Context protocol (@lit/context) already solves your specific sharing problem — it's designed exactly for passing data to arbitrarily nested descendants without manual slot-discovery plumbing.