The two previous lessons have fully resolved communication between <task-card> and <task-list>: an event goes up, a property comes down, and both components stay in sync without either knowing the other's internal structure. That pattern works because <task-card> and <task-list> have a direct parent-child relationship. But TaskFlow is about to need something different: a future <task-filter> component, with controls for filtering the visible task list, which is neither a child of <task-list> nor the other way around, but rather both will have to coexist as siblings under the same container. This lesson presents the standard pattern for solving communication between components with no direct relationship, and applies it by creating <task-board>, the orchestrator component that will, from now on, house TaskFlow's entire structure.
Contents
- The problem: two siblings that need to talk to each other
- The pattern of "lifting state up" to a common ancestor
- Creating
<task-board>as an orchestrator <task-board>listens to<task-list>- Preparing the slot for
<task-filter> - Alternatives for larger applications
- Closing: toward lifecycle and updates
- The problem: two siblings that need to talk to each other
Imagine the scenario, still to be built in this course, of <task-filter>: a component with, for example, a <select> to choose "show only urgent tasks" or "show only pending tasks." When the user changes that filter, <task-list> somehow has to find out and stop showing the cards that don't meet the chosen criterion. The problem is that, if both components are placed next to each other in the application's HTML...
...there is no parent-child relationship between them that would allow directly applying the patterns already seen. <task-filter> cannot pass a property to <task-list> (properties, as recalled in the previous lesson, only travel from a component to its own children in the template), and even if <task-filter> dispatched a custom event with bubbles: true and composed: true, that event would go up toward its common ancestors with <task-list>, not toward <task-list> directly, because DOM events have no notion of "send this to my sibling": they only go upward, never crossing laterally between different branches of the tree.
- The pattern of "lifting state up" to a common ancestor
The standard solution, not exclusive to Lit or Web Components (the same pattern is known, under the same name, in React and many other component-based UI libraries), consists of lifting the shared state up to a common ancestor of both siblings: instead of <task-filter> and <task-list> trying to talk directly to each other, a third component, parent to both, holds the piece of data they both need to share (in this case, the active filter criterion), and becomes the intermediary for all the communication:
<task-filter>, when the user changes the criterion, dispatches a custom event upward (exactly the same "child announces" pattern from lesson 05-02), but this time whoever listens is the common ancestor, not<task-list>directly.- The common ancestor updates its own state with the new filter criterion.
- The common ancestor passes that criterion down, as a property, to
<task-list>(exactly the same "property to the child" pattern from lesson 05-03). <task-list>uses that property to decide which tasks to show.
At no point do <task-filter> and <task-list> know each other or communicate directly: each only talks to their common parent, who knows both and decides how to coordinate the information between them. This is, in essence, the same pattern from the two previous lessons (event upward, property downward), applied twice in a row —once between <task-filter> and the ancestor, again between the ancestor and <task-list>— instead of just once between two directly related components.
- Creating
<task-board> as an orchestrator
<task-board> as an orchestratorTo apply this pattern, TaskFlow needs a new component to act as the common ancestor: <task-board>, the application's full board, which from now on will be the one containing <task-list> (and, later in the course, <task-filter>), and the one holding the state both need to share.
// src/components/task-board.js
import { LitElement, html, css } from 'lit';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-list.js';
class TaskBoard extends LitElement {
static properties = {
tareas: { type: Array },
};
static styles = [
estilosCompartidos,
css`
.tablero {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
`,
];
constructor() {
super();
this.tareas = [
{ id: 1, titulo: 'Preparar la demo del sprint', estado: 'en-progreso', prioridad: 4, urgente: true },
{ id: 2, titulo: 'Revisar el PR de autenticación', estado: 'pendiente', prioridad: 2, urgente: false },
{ id: 3, titulo: 'Desplegar a producción', estado: 'hecha', prioridad: 5, urgente: false },
];
}
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
<task-list .tareas="${this.tareas}"></task-list>
</div>
`;
}
}
customElements.define('task-board', TaskBoard);This change moves to <task-board> a responsibility <task-list> used to have: the full tareas array, with its initial data, is no longer initialized inside <task-list>, but in <task-board>, and flows down to <task-list> as a property, with the already familiar dot binding (.tareas="${this.tareas}"). <task-list>, for its part, no longer has a constructor that invents its own sample data: from now on, it always receives tareas from outside, just as <task-card> always receives titulo or estado from <task-list>. This redistribution of responsibilities is exactly what's needed so that, later on, <task-filter> can be added at the same level as <task-list>, both as children of <task-board>, without either of them needing to maintain the full tasks array on its own.
<task-board> listens to <task-list>
<task-board> listens to <task-list>The tarea-cambiada event, which in lesson 05-03 went up from <task-card> to <task-list>, stays exactly the same: nothing changes in <task-card>. What does change is where the logic deciding how to update the tareas array now lives: since that array has moved to live in <task-board>, it is <task-board> that must take care of updating it when a card changes state, and <task-list> is reduced to forwarding the event upward, without handling it itself:
// src/components/task-list.js
class TaskList extends LitElement {
static properties = {
tareas: { type: Array },
};
render() {
return html`
<section>
<h2>Mis tareas</h2>
<div class="lista">
${this.tareas.map(
(tarea) => html`
<task-card
.titulo="${tarea.titulo}"
.estado="${tarea.estado}"
.prioridad="${tarea.prioridad}"
.urgente="${tarea.urgente}"
@tarea-cambiada="${(event) => this.reenviarTareaCambiada(tarea.id, event)}"
></task-card>
`
)}
</div>
</section>
`;
}
reenviarTareaCambiada(idTarea, event) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { idTarea, nuevoEstado: event.detail.nuevoEstado },
bubbles: true,
composed: true,
})
);
}
}// src/components/task-board.js
class TaskBoard extends LitElement {
// ...properties, styles y constructor sin cambios...
gestionarTareaCambiada(event) {
const { idTarea, nuevoEstado } = event.detail;
this.tareas = this.tareas.map((tarea) =>
tarea.id === idTarea ? { ...tarea, estado: nuevoEstado } : tarea
);
}
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
<task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
</div>
`;
}
}This forwarding is worth pausing on for a moment, since it's the new piece in this lesson: <task-list> receives tarea-cambiada from a specific <task-card> (it knows, thanks to the closure over tarea.id already seen in the previous lesson, which task it affects), but instead of deciding for itself how to update the array, it builds and dispatches a new custom event, also named tarea-cambiada, this time with idTarea explicitly included in its detail (something not needed in the original version, because back then it was <task-list> itself that already knew tarea.id from the map's closure; now that piece of data needs to travel explicitly in the event, because whoever will consume it, <task-board>, has no access to that closure variable). The new event, with bubbles: true and composed: true just like the original, goes up from <task-list> to <task-board>, which is finally the one that applies the immutable-update logic already familiar from the previous lesson.
It is perfectly valid, and in fact common in real applications with deeper hierarchies, for an intermediate component like <task-list> not to handle an event itself but simply let it pass upward, adding whatever extra information is needed along the way. This chained forwarding is, in essence, the same idea as native DOM event bubbling (seen in lesson 05-01), but applied explicitly and in a controlled way between different levels of custom components.
- Preparing the slot for
<task-filter>
<task-filter>With <task-board> already in place as the orchestrator, the ground is now perfectly prepared so that, when <task-filter> is built (a task that belongs to a later module in this course), it can be added at exactly the same level as <task-list>, as a sibling under the same <task-board>:
// Preview of what <task-board> will look like later in the course,
// once <task-filter> exists (not implemented yet in this module)
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
<task-filter @filtro-cambiado="${this.gestionarFiltroCambiado}"></task-filter>
<task-list
.tareas="${this.tareasFiltradas()}"
@tarea-cambiada="${this.gestionarTareaCambiada}"
></task-list>
</div>
`;
}There's no need to implement <task-filter> or tareasFiltradas() in this module (that development will arrive later in the course, once more templating tools are available); what matters for this lesson is to note that the structure just built —<task-board> as the single point that knows both <task-list> and, in the future, <task-filter>— is already capable of accommodating that additional component without any architectural change: <task-filter> would dispatch its own event upward (for example, filtro-cambiado), <task-board> would listen to it and update a new internal state or property with the active criterion, and that criterion would be combined with this.tareas (for example, in a tareasFiltradas() method) before passing it down to <task-list>. Neither <task-filter> nor <task-list> would ever need to know about each other.
- Alternatives for larger applications
The pattern of lifting state up to a common ancestor, applied in this lesson with a single level of <task-board>, scales reasonably well as long as the application doesn't grow too deep. But it's worth knowing that, in larger applications, with hierarchies of many levels or with many components needing the same shared piece of data, this pattern can become uncomfortable: if the common ancestor were several levels up, every intermediate level would have to forward events upward and properties downward, as <task-list> did in section 4, purely to act as a middleman for data it doesn't even use itself. This problem, in UI component literature, is sometimes known as prop drilling, and two common alternatives avoid it:
- A global event bus: a shared object, accessible from any component in the application (usually imported as a JavaScript module), on which any component can dispatch events and any other can subscribe, without going through any intermediate component hierarchy. It solves the problem of communication between distant components, but at the cost of losing the clear traceability of "who talks to whom" that the component tree and its events with
bubbles/composeddo offer. @lit/context, Lit's own shared-context API, designed specifically so that an ancestor component publishes a value and any descendant, at any depth, can consume it directly, without intermediate levels needing to forward anything manually. It is, in general, the more elegant alternative for this kind of problem within the Lit ecosystem, and will be studied in detail in module 7, "Advanced Template Directives and Features."
For TaskFlow's current size, with a hierarchy of only three levels (<task-board> → <task-list> → <task-card>), the pattern of lifting state up to the common ancestor, with events and properties forwarded manually, is perfectly adequate and doesn't need either of these two alternatives; it's worth knowing them in order to recognize, in a larger real project, the moment when it does make sense to reach for them.
- Closing: toward lifecycle and updates
With <task-board> now coordinating <task-list> and, in the future, <task-filter>, TaskFlow already has all the communication structure it needed: events going up telling what happened, properties coming down with the updated state, and a common ancestor acting as intermediary when two components don't have a direct parent-child relationship.
Common Mistakes and Tips
- Having
<task-filter>and<task-list>try to communicate directly: for example, storing a reference to<task-list>inside<task-filter>withdocument.querySelectorand calling its methods directly. This breaks the encapsulation of both components (each one ends up depending on the existence and internal API of the other) and is exactly what this lesson's pattern avoids. - Forgetting to include, in the forwarded event's
detail, the information that would be lost going up a level: as explained in section 4,<task-list>had to explicitly addidTareato the forwarded event'sdetail, because that piece of information, available in<task-list>thanks to themap's closure, would not otherwise be accessible to<task-board>. - Duplicating state across several levels of the hierarchy: if both
<task-board>and<task-list>kept their own copy of thetareasarray, keeping them correctly in sync would become needlessly complicated and error-prone; shared state must live in a single place (the common ancestor), and descendants must limit themselves to receiving it as a property, never maintaining their own independent copy. - Reaching for a global event bus or
@lit/contextbefore actually needing it: for small hierarchies, like this module's TaskFlow, the pattern of lifting state up to a common ancestor is simpler to follow and debug than introducing an extra layer of indirection; it's best to reserve those alternatives, mentioned in section 6, for when the hierarchy or the number of components sharing a piece of data truly justifies it.
Exercises
- Add to
<task-board>acontarTareasPendientes()method that returns how many tasks inthis.tareashaveestado === 'pendiente', and display it in<task-board>'s template, next to the<h1>TaskFlow</h1>, as a small summary (for example, "3 tareas pendientes"). Explain why this logic fits better in<task-board>than in<task-list>. - Suppose
tarea-eliminada(from exercise 1 of lesson 05-02) is added to TaskFlow's full flow. Write the corresponding forwarding in<task-list>(similar toreenviarTareaCambiada) and the corresponding handler in<task-board>that removes the task fromthis.tareasimmutably. - Explain, in your own words and drawing on section 2, why it would have been a mistake to solve this lesson's problem by having
<task-card>dispatchtarea-cambiadadirectly against a stored reference to<task-list>(for example, passing it that reference as a property from<task-board>), instead of letting the event bubble naturally up to wherever it's supposed to be listened to.
Solutions
contarTareasPendientes() {
return this.tareas.filter((tarea) => tarea.estado === 'pendiente').length;
}
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
<p>${this.contarTareasPendientes()} tareas pendientes</p>
<task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
</div>
`;
}This logic fits in <task-board> because it needs access to the full tareas array, which is precisely the data <task-board> holds as the common ancestor; <task-list>, if it had this same logic, would need to duplicate the counting criterion and apply it also to its own copy of tareas, when in reality both should always operate on the same source of truth, the one living in <task-board>.
// task-list.js
reenviarTareaEliminada(idTarea) {
this.dispatchEvent(
new CustomEvent('tarea-eliminada', {
detail: { idTarea },
bubbles: true,
composed: true,
})
);
}// task-board.js
gestionarTareaEliminada(event) {
const { idTarea } = event.detail;
this.tareas = this.tareas.filter((tarea) => tarea.id !== idTarea);
}<task-list
.tareas="${this.tareas}"
@tarea-cambiada="${this.gestionarTareaCambiada}"
@tarea-eliminada="${this.gestionarTareaEliminada}"
></task-list>- Passing a direct reference to
<task-list>as a property of<task-card>(or from<task-board>to<task-card>, skipping<task-list>) would force<task-card>to know, even if indirectly, about the existence and API of<task-list>, exactly the dependency lesson 05-02 was trying to avoid:<task-card>would stop being an isolated, reusable component, and would become dependent on a specific piece of TaskFlow's hierarchy. In addition, that direct reference would completely bypass the bubbling mechanism withbubbles/composed, losing the advantage pointed out in lesson 05-02 that any number of listeners, at any level of the hierarchy, can listen to the same event without the emitter needing to know anything about them; with a direct reference to a single recipient, that flexibility disappears.
Conclusion
This lesson has solved the problem of communicating between two components with no direct parent-child relationship, using the pattern of lifting state up to a common ancestor, applying the already familiar cycle of event upward and property downward twice in a row. <task-board> has been born as that common ancestor, with the tareas array as the single source of truth, <task-list> forwarding events instead of handling them on its own, and the ground already prepared for <task-filter> to be added later as a sibling of <task-list> without either needing to know about the other. Two alternatives for larger hierarchies were also mentioned, without going into detail: a global event bus and @lit/context, the latter a more elegant alternative that will be studied in detail in module 7.
All this exchange of events and properties built throughout module 5 has a common effect, not yet explained in depth: every time a reactive property changes —whether estado in <task-card> or tareas in <task-board>— Lit schedules an update, but so far this course has taken for granted, without pausing to explain it, exactly when that update happens, in what order the different steps of the process run, and what possibilities Lit offers for hooking into specific moments of that cycle. That is exactly the content of module 6, "Lifecycle and Advanced Behavior."
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
