TaskFlow doesn't make much sense if it can only show one task at a time. A real Kanban board needs to display entire collections of tasks: all the pending ones, all the ones in progress, all the ones belonging to a specific person. This lesson explains how to iterate over an array of data inside a Lit template using Array.map, what identity problems can show up when rendering lists that change over time, and why in those cases a specific directive—covered in detail later in the course—is preferable. With this technique you'll build <task-list>, TaskFlow's first component capable of showing several tasks from a collection of data.
Contents
- Template lists: an array is an interpolatable value
Array.mapas the primary technique- The identity problem when updating lists
- The
repeatdirective and the concept ofkey: a passing mention - When
mapis enough and whenrepeatis preferable - Building
<task-list>
- Template lists: an array is an interpolatable value
In the lesson "Lit's Template Engine" it was mentioned, in passing, that an array of values is valid content to interpolate inside an html template. It's time to stop and look closer at that idea: when the result of an interpolation is an array, Lit doesn't convert it to text (you won't see something like "tarea1,tarea2,tarea3" on screen); instead, it renders each element of the array as if it were an independent interpolation, placing each one right after the previous one.
This example, although not very useful as written, already demonstrates the behavior: the three strings in the array would appear one after another inside the <ul>, with no <li> tag wrapping each one individually, because the array contains only plain text. The genuinely useful case appears when, instead of an array of plain strings, you interpolate an array of html templates, one per element of the original data. That's where Array.map comes in.
Array.map as the primary technique
Array.map as the primary techniqueThe standard pattern for rendering a list in Lit is to use the array method map, already available in standard JavaScript with no dependency on Lit, to transform an array of data into an array of html templates:
import { LitElement, html } from 'lit';
class ListaDeTareas extends LitElement {
constructor() {
super();
this.tareas = ['Comprar pan', 'Revisar el PR', 'Llamar al cliente'];
}
render() {
return html`
<ul>
${this.tareas.map((tarea) => html`<li>${tarea}</li>`)}
</ul>
`;
}
}Let's analyze the key expression: this.tareas.map((tarea) => html\
. The mapmethod walks through each element of thethis.tareasarray and, for each one, runs the given function, which in this case returns anhtmltemplate with ancontaining that text. The result ofmapis no longer the original array of strings, but a **new array ofhtmltemplates**, one per task. That new array is exactly the kind of value described in section 1: an array whose content is templates, so Lit renders it by placing eachone after another inside the- `.
This pattern is so common in Lit (and, generally, in any modern JavaScript-based UI framework) that it's worth memorizing as a one-line construct: array.map((elemento) => html\...`)`. You can use it anywhere in a template where it makes sense to repeat a structure for each element of a collection: table rows, dropdown options, cards on a board, menu items.
If the objects in the collection are more complex than plain strings, the pattern works exactly the same way, accessing each object's properties inside the function:
render() {
return html`
<ul>
${this.tareas.map((tarea) => html`
<li>${tarea.titulo} — ${tarea.estado}</li>
`)}
</ul>
`;
}
- The identity problem when updating lists
As long as a list renders once and never changes again, map presents no problems at all. The trouble shows up when the list changes over time: tasks are added, removed, or reordered (for example, by dragging a card from one column to another on a Kanban board, something TaskFlow will do in later modules).
When Lit re-runs render() with a different array (even if it has only changed slightly from the previous one), and that array is processed with map as in section 2, Lit compares, by default, the position of each resulting template with the position it occupied in the previous render, not the logical content of each element. In other words: "the template that was at position 3 last time" gets compared against "the template that's at position 3 this time", regardless of whether it's really the same original task or a completely different one that simply landed on that position after the array was reordered.
In practice, this can cause two kinds of problems:
- Inefficiency: if a new task is inserted at the beginning of a list of a hundred elements, every position shifts down by one. Comparing by position, Lit may end up updating the content of all one hundred cards (because "the element at position N" changed at all one hundred positions), instead of recognizing that ninety-nine cards are exactly the same as before and only one new card needs to be inserted at the start.
- Loss of DOM state: if some node inside one of those templates held browser-specific state (for example, the focus on a text field, an animation in progress, text selected by the user), reordering by position can make that state "jump" to a different logical element than the one that originally held it, because Lit, having no way to know which element is "the same one as before", may reuse the physical node from a position to now represent different data.
- The
repeat directive and the concept of key: a passing mention
repeat directive and the concept of key: a passing mentionTo solve this problem, Lit offers a directive called repeat, designed specifically for lists that change over time (elements get inserted, removed, or reordered). Its basic form is as follows:
import { repeat } from 'lit/directives/repeat.js';
render() {
return html`
<ul>
${repeat(
this.tareas,
(tarea) => tarea.id,
(tarea) => html`<li>${tarea.titulo}</li>`
)}
</ul>
`;
}repeat takes three arguments: the array of data, a function that computes a unique key (key) for each element—typically a stable identifier, such as tarea.id—and a function that generates the template for each element, just like you would with map. Thanks to that key, Lit can identify each element by its real logical identity, not by its position in the array: if a task with id: 'abc' moves from position 3 to position 0, repeat recognizes that it's still the same task and moves the existing DOM node, instead of destroying it and recreating a new one with different content.
This directive, along with the rest of Lit's catalog of built-in directives, is studied in depth in module 7, "Directives and Advanced Template Features". It's mentioned here ahead of time only so you know a concrete solution to the problem described in section 3 exists, and so you recognize the syntax if you come across it before reaching that module.
- When
map is enough and when repeat is preferable
map is enough and when repeat is preferableNot every list needs repeat; using it "just in case" on every list adds a dependency and a layer of complexity that sometimes brings nothing to the table. A practical guide for deciding:
| Situation | Recommended technique |
|---|---|
| The list renders once and never changes again during the component's life | Array.map is enough |
| The list changes, but it's always rebuilt entirely from scratch (for example, the array is fully replaced on every update, without keeping any elements) | Array.map remains reasonable |
| The list undergoes frequent insertions, removals, or reorderings, and preserving each element's DOM state matters (focus, animations, form input) | repeat, with a stable key such as id |
| The list's elements are complex or expensive to re-render, and you want to avoid recomputing the ones that haven't changed logical position | repeat |
In TaskFlow, for example, when card dragging between Kanban board columns gets implemented later in the course, that will be exactly the scenario where repeat makes a noticeable difference: cards will move position without losing their identity or their internal state. For now, with static sample data that doesn't change during the component's execution, Array.map is the right tool, and the one that will be used for the rest of this module.
- Building
<task-list>
<task-list>With the Array.map technique already explained, it's time to build TaskFlow's second real component: <task-list>. Its responsibility is simple: receive a collection of tasks and render a <task-card> for each one. Just like in previous lessons, the collection will be declared as a plain instance field, not as a reactive property (that comes in module 3).
First, make sure task-card.js exports or at least registers its element as in previous lessons (the version from the lesson "Conditional Rendering", with a status badge, is assumed here). Then, create src/components/task-list.js:
import { LitElement, html } from 'lit';
import './task-card.js';
class TaskList extends LitElement {
constructor() {
super();
// Plain instance array, still without real reactivity (module 3).
this.tareas = [
{ id: 1, titulo: 'Preparar la demo del sprint', estado: 'en-progreso' },
{ id: 2, titulo: 'Revisar el PR de autenticación', estado: 'pendiente' },
{ id: 3, titulo: 'Desplegar a producción', estado: 'hecha' },
];
}
render() {
return html`
<section>
<h2>Mis tareas</h2>
<div class="lista">
${this.tareas.map(
(tarea) => html`<task-card></task-card>`
)}
</div>
</section>
`;
}
}
customElements.define('task-list', TaskList);There's an important detail worth pointing out explicitly at this point in the course: on the line html\inside themap, an instance of is being created for each task in the array, but **no specific data from that task is being passed to it yet**. Sincestill has no real reactive properties, there's no clean way yet to tell it "show the title of *this particular* task"; that's why, at this point in the course, the three cards shown bywill all look identical, with the fixed values
This limitation is, precisely, the clearest argument for why module 3 is essential: as soon as <task-card> declares real reactive properties, you'll be able to write something like html\<task-card .titulo="${tarea.titulo}" .estado="${tarea.estado}">`(using the dot-property syntax seen in the previous lesson), and each card will then show the data of its own task. It's important to make this limitation clear now, rather than leaving it as a confusing surprise:
To use the new component, update index.html:
<!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <title>TaskFlow</title> <script type="module" src="/src/components/task-list.js"></script> </head> <body> <h1>TaskFlow</h1> <task-list></task-list> </body> </html>
Notice that index.html no longer needs to import task-card.js directly: it's enough that task-list.js imports it (import './task-card.js';), because that import still registers the <task-card> element in the browser, and <task-list> uses it internally in its own template. When you reload the page, you should see the heading "Mis tareas" followed by three cards, one per element of the this.tareas array, even though all three still show, for now, the same sample content.
Common Mistakes and Tips
- Forgetting the child component's
import: if<task-list>doesn't importtask-card.js(directly, or indirectly through some other file that does), the browser won't know what to do with the<task-card>tag and will treat it as an unknown element, with no visible error beyond the fact that nothing shows up inside it. - Expecting each
<task-card>to show different data already at this module: as explained in section 6, without reactive properties there's still no clean way to pass different data to each instance; the cards will look the same until module 3, and this is intentional. - Using the
mapindex as if it were a stable key: it's tempting to writethis.tareas.map((tarea, indice) => ...)and think ofindiceas a unique identifier for each task. It isn't: the index depends on the current position in the array, which changes if elements are reordered or a new one is inserted at the start. To stably identify an element (for example, when usingrepeatlater in the course), you need to use an identifier that belongs to the data itself, such astarea.id. - Rendering huge lists with no pagination or virtualization strategy:
Array.mapworks fine for lists of a reasonable size (tens or even a few hundred elements), but rendering thousands of elements at once can become slow regardless of the library used. This large-scale performance consideration is revisited in module 9, "Testing and Best Practices".
Exercises
- Add a fourth task to
<task-list>'sthis.tareasarray and check that a fourth card appears when you reload the page. - Modify
<task-list>'srender()so that, before the list of cards, it shows a paragraph with the total number of tasks, usingthis.tareas.lengthinterpolated directly (no need formap, since it's a single value). - Investigate (by checking Lit's official documentation on the
repeatdirective, or section 4 of this lesson) what would happen differently, in terms of which DOM nodes get reused, ifrepeatwere used withtarea.idas the key instead ofArray.map, when inserting a new task at the start of the array. Write your answer in your own words, with no need to code it yet.
Solutions
this.tareas = [
{ id: 1, titulo: 'Preparar la demo del sprint', estado: 'en-progreso' },
{ id: 2, titulo: 'Revisar el PR de autenticación', estado: 'pendiente' },
{ id: 3, titulo: 'Desplegar a producción', estado: 'hecha' },
{ id: 4, titulo: 'Actualizar la documentación', estado: 'pendiente' },
];When you reload the page, Array.map automatically generates a fourth <task-card> template, with no need to touch the rest of render().
render() {
return html`
<section>
<h2>Mis tareas</h2>
<p>Total: ${this.tareas.length} tareas</p>
<div class="lista">
${this.tareas.map((tarea) => html`<task-card></task-card>`)}
</div>
</section>
`;
}- With
Array.map, when a new task is inserted at the start of the array, every existing task ends up occupying a different position than before (what was position 0 becomes position 1, and so on); since Lit compares by position in this case, it may end up rebuilding or updating the content of every existing card, not just inserting a new one at the start. Withrepeatandtarea.idas the key, Lit identifies each task by its identifier, regardless of the position it occupies in the array; when inserting a new task at the start, it would recognize that the other tasks are the same ones as before (sameid) and simply insert a new DOM node at the start, without touching or rebuilding the already-existing nodes of the other cards.
Conclusion
In this lesson you learned to render collections of data inside a Lit template by combining Array.map with interpolation of arrays of html templates, the standard technique for lists that don't change in complex ways during the component's life. You've also understood why lists that get reordered or modified frequently can suffer identity problems when compared only by position, and why the repeat directive with a key (key) exists for those cases, whose details will be studied in module 7. With this foundation, TaskFlow now has <task-list>, capable of walking an array of sample tasks and generating a <task-card> for each one, although still unable to pass different data to each card: that piece is deliberately left pending for module 3.
In the last lesson of this module, "The Rendering Cycle", you'll take a step back to understand something you've been taking for granted so far: how and when Lit exactly decides when to re-run render(), why that process is asynchronous, and why you should never touch the DOM by hand inside that method, closing out the module before jumping to real reactive properties in module 3.
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
