Throughout this module you've written several templates inside render(), but several examples have explicitly pointed out that changing an instance field from the browser console doesn't update the screen. Before understanding why (and fixing it, in module 3, with real reactive properties), it's worth understanding how Lit's rendering cycle actually works: when it decides to re-run render(), why that process is asynchronous, and what rules you need to follow when writing that method so you don't end up with strange behavior. This lesson closes out module 2 with that conceptual foundation, and sets the stage for module 3, where <task-card> and <task-list> will finally stop depending on instance fields with no reactivity.

Contents

  1. render() doesn't call itself: it needs a trigger
  2. requestUpdate(): the manual trigger, at a high level
  3. Why updates are asynchronous
  4. Batching several changes into a single render
  5. render() as a pure function of its state
  6. Why you shouldn't touch the DOM by hand inside render()
  7. Closing out the module: toward real reactive properties

  1. render() doesn't call itself: it needs a trigger

Throughout this whole module, render() has always run at the same moment: when the component is first inserted into the page. That explains why, in previous examples, changing an instance field from the browser console (elemento.titulo = 'Otro título') produces no visible change: render() already ran once, when the component was inserted, and nothing has told Lit it needs to run again.

This isn't a flaw or a temporary limitation of the course: it's simply how Lit's internal mechanism works. render() doesn't get called again automatically just because; it needs an explicit trigger that tells Lit "something has changed, time to update". That trigger, in normal Lit usage, is a change to a reactive property declared with static properties (or with the @property decorator), as briefly mentioned in the anatomy lesson in module 1. When a new value is assigned to a reactive property, Lit detects it internally (through a setter mechanism it installs automatically on those specific properties) and schedules an update.

The plain instance fields used in this module (this.titulo, this.estado, this.tareas...) are ordinary JavaScript fields, with no special setter installed by Lit on top of them. Assigning them a new value is a perfectly normal JavaScript operation that Lit doesn't even get to "see". That's why, without reactive properties, there's no automatic trigger at all: module 3 will solve exactly this missing piece.

  1. requestUpdate(): the manual trigger, at a high level

Even though the usual trigger is a change to a reactive property, Lit also exposes a method that any component can call directly to force an update without relying on that mechanism: this.requestUpdate().

import { LitElement, html } from 'lit';

class TaskCard extends LitElement {
  constructor() {
    super();
    this.titulo = 'Preparar la demo del sprint';
  }

  cambiarTitulo(nuevoTitulo) {
    this.titulo = nuevoTitulo;
    this.requestUpdate(); // we explicitly tell Lit: "update"
  }

  render() {
    return html`<h3>${this.titulo}</h3>`;
  }
}

customElements.define('task-card', TaskCard);

In this example, cambiarTitulo modifies the this.titulo field (a plain instance field, just like the rest of the module) and, right after, calls this.requestUpdate() to explicitly ask Lit to re-run render(). If you called elemento.cambiarTitulo('Nuevo título') from the browser console, this time you would see the change reflected on screen, because there was an explicit call to requestUpdate().

It's worth placing this piece correctly within the course: requestUpdate() is, essentially, the low-level mechanism Lit uses internally when it detects a change in a reactive property; knowing about it now helps you understand that the "magic" of module 3's reactive properties isn't magic at all, but exactly this same call, fired automatically by Lit at the right moment. The detailed study of requestUpdate()—its optional parameters, in which specific cases it's worth calling it manually even with reactive properties already declared—belongs to modules 3 and 6; for now it's enough to recognize that it exists and roughly what it's for.

  1. Why updates are asynchronous

A quirk of Lit's rendering cycle that surprises anyone seeing it for the first time is that calling requestUpdate() (or, in module 3, changing a reactive property) doesn't update the DOM immediately, on that same line of code. Instead, Lit schedules the update to run on the next "microtask" of the JavaScript event loop, a concept that belongs to the web platform (not to Lit) that determines when queued code runs once the current synchronous task finishes.

cambiarTitulo(nuevoTitulo) {
  this.titulo = nuevoTitulo;
  this.requestUpdate();

  // At this point, the DOM has NOT been updated yet.
  console.log(this.shadowRoot.querySelector('h3').textContent);
  // Shows the PREVIOUS title, not the new one.
}

If you need to wait until the update has actually been applied to the DOM before continuing to run code, Lit offers a special property, this.updateComplete, which is a Promise that resolves exactly when the pending update finishes:

async cambiarTitulo(nuevoTitulo) {
  this.titulo = nuevoTitulo;
  this.requestUpdate();

  await this.updateComplete;

  // Now the DOM does reflect the new title.
  console.log(this.shadowRoot.querySelector('h3').textContent);
}

There's no need to memorize updateComplete in detail at this point in the course (it will come back up when it's relevant, later on); what matters is internalizing the general idea: a Lit update doesn't happen at the exact instant the change that triggers it occurs, but a little afterward, asynchronously.

  1. Batching several changes into a single render

The fact that updates are asynchronous isn't just a curious technical detail: it's what lets Lit perform an important optimization called batching. If, within the same synchronous function, several reactive properties are modified one after another, Lit doesn't run render() once per change; it waits until the end of that function and runs render() only once, with all the changes already applied at the same time.

actualizarVariosCampos() {
  this.titulo = 'Nuevo título';
  this.estado = 'hecha';
  this.prioridad = 5;
  // Even though three reactive properties were modified,
  // Lit will only run render() once, not three times.
}

This batching is possible precisely thanks to the delay until the next microtask explained in the previous section: since the actual update doesn't happen immediately, Lit has a small window of time to "collect" every change produced during the current synchronous execution and apply them together in a single render() pass. If updates were synchronous and immediate, each of the three assignments in the previous example would trigger its own complete run of render(), wasting work on two intermediate renders whose result never even gets shown (because it's immediately overwritten by the next change).

This is one of the underlying reasons, together with the already-cached template parsing explained in the module's first lesson, that Lit can afford a "just change the property and you're done" model without worrying about the cost of frequent updates: the system itself automatically batches the work.

  1. render() as a pure function of its state

An idea worth pinning down clearly, because it guides how you should write any render(): this method must behave like a pure function of the component's current state. That is, given the same internal state (the same values for its properties and fields), render() must always return the same result, with no observable side effect outside itself.

In practice, this means avoiding, inside render():

  • Modifying any property or field of the component itself (this.algo = ...), because that could trigger a new update from within the current update, generating loops that are hard to debug.
  • Making network requests, setting timers, or performing any operation with external effects: render() can end up running several times due to Lit's own internal workings, and it's not the right place to fire off operations that shouldn't repeat unnecessarily.
  • Depending on external data that changes without Lit knowing about it, such as reading Math.random() or Date.now() directly and expecting a stable result; if those values need to appear in the interface, it's better to compute them once (for example, in the constructor or in a lifecycle callback, as appropriate) and store them as part of the component's state.

This philosophy—render() as a direct, predictable translation from "current state" to "current HTML", with no surprises or side effects—is shared by practically every modern UI library, not just Lit, and it's what makes it possible to reason about a component simply by looking at its data, without having to trace a chain of hidden side effects.

  1. Why you shouldn't touch the DOM by hand inside render()

Directly related to the pure-function idea from the previous section is a very specific rule: you should never manipulate the DOM manually inside render(), neither with document.querySelector, nor with this.shadowRoot.querySelector, nor by directly assigning innerHTML on some node.

// Anti-pattern: do NOT do this inside render()
render() {
  const resultado = html`<article><h3>${this.titulo}</h3></article>`;

  // Wrong: manually modifying the DOM during the render itself
  const posibleH3 = this.shadowRoot?.querySelector('h3');
  if (posibleH3) {
    posibleH3.classList.add('ya-renderizado');
  }

  return resultado;
}

The reason is twofold. First, as explained in the module's first lesson, it's Lit that decides, based on its analysis of the template, how and when to update each specific DOM node; intervening manually competes with that mechanism and can make Lit lose track of a node's actual state, producing inconsistent updates. Second, at the moment render()'s body runs, the DOM resulting from this specific execution may not have been applied to the page yet: render() only returns a description of what should be shown, it doesn't apply it itself, so looking up nodes with querySelector inside render() itself may find the DOM of the previous version, not the one currently being generated.

If a component needs to run logic that depends on the already-updated DOM (for example, measuring an element's real size after rendering it), the right place to do that is one of the lifecycle callbacks seen in module 1—typically updated() or firstUpdated()—which Lit guarantees run after the DOM already reflects the result of render(). The detailed workings of those callbacks are, again, module 6 content; for now it's enough to keep the general rule in mind: render() describes, it doesn't manipulate.

  1. Closing out the module: toward real reactive properties

With this lesson, module 2's journey is complete. You've learned that html is a function that separates a template's fixed structure from its dynamic values, enabling efficient updates without a Virtual DOM; that inside ${} you can interpolate text, attributes, DOM properties, and boolean values, as well as arbitrary JavaScript expressions; that conditional rendering and list rendering need no special syntax, just standard JavaScript operators and methods (?:, &&, Array.map); and, in this last lesson, that this whole rendering process is triggered through requestUpdate(), happens asynchronously and in batches, and must treat render() as a pure function with no side effects on the DOM.

Throughout the module, however, one limitation has been repeated deliberately: <task-card> and <task-list> have used plain instance fields (this.titulo, this.tareas...) precisely so attention could stay focused on how templates are written, without the added complexity of the reactivity system. As you saw in section 1 of this lesson, those fields don't trigger any automatic update because Lit has no way of knowing they've changed: it only watches the properties that have been explicitly declared as reactive.

That's exactly the missing piece, and it gets solved in module 3, "Reactive Properties and State". There you'll learn to declare titulo, estado, prioridad, and tareas as real reactive properties using static properties, understanding how Lit automatically installs the change-detection mechanism on top of them, how to distinguish between public properties and internal state with @state, and how properties relate to HTML attributes. At that point, everything you've learned in this module 2 about templates, conditionals, lists, and the rendering cycle will combine with real reactivity, and <task-card> and <task-list> will finally start updating themselves on their own when their data changes.

Common Mistakes and Tips

  • Expecting the DOM to be updated right after requestUpdate(): as explained in section 3, the update is asynchronous; if you need to check the DOM after a change, you have to wait for this.updateComplete (with await) or check it in a later lifecycle callback.
  • Calling requestUpdate() repeatedly and unnecessarily: if a component already uses properly declared reactive properties (module 3 content), you almost never need to call requestUpdate() manually; Lit already does it automatically upon detecting the change. Calling it "just in case" adds noise and can hide real bugs in how the properties are declared.
  • Modifying the component's own properties inside render(): as explained in section 5, this can generate update loops that are hard to debug, where each render() unintentionally triggers another extra render().
  • Manipulating the internal DOM with querySelector inside render(): as explained in section 6, the right place for that kind of logic is the callbacks that run after the update (updated(), firstUpdated()), not render() itself.

Exercises

  1. Go back to the example from section 2 (cambiarTitulo with requestUpdate()) and extend it so that, besides the title, it also changes the status and the priority in the same call. Reason, based on section 4, whether this will trigger one or several runs of render().
  2. Write, in pseudocode or in real JavaScript if you prefer, a method esperarYComprobar() that changes this.titulo, calls requestUpdate(), and then uses await this.updateComplete to, right after, read the already-updated title from the real DOM with this.shadowRoot.querySelector('h3').textContent.
  3. Look at some component you wrote in earlier lessons of this module (task-card.js or task-list.js) and identify whether any line inside render() breaks the rule from section 6 (manually manipulating the DOM). If there isn't one, explain why those components, as written, already follow that rule naturally.

Solutions

cambiarVariosCampos(nuevoTitulo, nuevoEstado, nuevaPrioridad) {
  this.titulo = nuevoTitulo;
  this.estado = nuevoEstado;
  this.prioridad = nuevaPrioridad;
  this.requestUpdate();
}

Even though requestUpdate() is called only once explicitly in this example (and even if it were called three times in a row, once per field), Lit batches any update requested during the same synchronous execution and only runs render() once, as explained in section 4, already showing all three new values at once.

async esperarYComprobar() {
  this.titulo = 'Título actualizado';
  this.requestUpdate();

  await this.updateComplete;

  const textoActual = this.shadowRoot.querySelector('h3').textContent;
  console.log(textoActual); // Now yes, "Título actualizado"
}
  1. In this module's task-card.js and task-list.js components, render() always limits itself to building and returning an html template from instance fields (this.titulo, this.estado, this.tareas...), never calling document.querySelector, this.shadowRoot.querySelector, or directly assigning innerHTML. So they naturally follow the rule from section 6: they describe the desired result through html, without manipulating the DOM on their own, leaving that responsibility entirely to Lit.

Conclusion

In this last lesson of module 2 you understood the mechanism that decides when and how Lit re-runs render(): a trigger (usually a change to a reactive property, or a manual call to requestUpdate()), an asynchronous process that resolves on the next microtask and batches several changes into a single update, and a fundamental design rule: render() must behave like a pure function of its state, without modifying its own properties or touching the DOM by hand inside it.

This closes out the "Reactive Templates and Rendering" module: you now know how to write dynamic templates with html, interpolate every kind of value, render conditionally and as lists, and you understand the internal cycle that applies those changes to the DOM. The one thing deliberately missing throughout the whole module has been the real automatic trigger: properties that, when they change, fire up this machinery on their own. That's exactly the starting point of module 3, "Reactive Properties and State", where you'll turn <task-card>'s and <task-list>'s plain fields into real reactive properties with static properties, and where every TaskFlow card will finally be able to show its own task's data dynamically and update itself on its own whenever that data changes.

Lit Course

Module 1: Introduction to Lit and Web Components

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved