The previous lesson resolved the "external" lifecycle of a custom element: when it enters and when it leaves the DOM. But Lit adds, on top of that inherited standard cycle, a second cycle of its own, much more frequent during a component's normal life: the one that fires every time a reactive property changes and Lit decides to run render() again. Module 2 already explained that this process is asynchronous and grouped into microtasks, and lesson 03-01 mentioned updateComplete in passing; this lesson completes the map, precisely ordering the phases of an update and the exact points at which a component can hook into each of them.

Contents

  1. The full update cycle, step by step
  2. shouldUpdate: the veto, mentioned for completeness
  3. willUpdate: deriving state before rendering
  4. render(): the point you already know
  5. firstUpdated: only once, after the first render
  6. updated: after every render, with the DOM already painted
  7. updateComplete: the promise that closes the cycle
  8. Comparison table of the five hook points
  9. Applying willUpdate to <task-card>: urgency derived from the date
  10. Wrap-up: toward reuse with controllers

  1. The full update cycle, step by step

When a reactive property changes (or this.requestUpdate() is called manually, as seen in module 2), Lit doesn't simply run render() and stop there: it goes through a fixed sequence of steps, always in the same order, each of which can be overridden as a class method:

property change
      │
      ▼
shouldUpdate(changedProperties)   ← can return false and halt the whole cycle right here
      │
      ▼
willUpdate(changedProperties)     ← before rendering; the DOM doesn't yet reflect the change
      │
      ▼
render()                          ← returns the template; already known from module 2
      │
      ▼
   (Lit applies the result of render() to the real DOM)
      │
      ▼
firstUpdated(changedProperties)   ← only the first time, with the DOM already updated
      │
      ▼
updated(changedProperties)        ← every time, with the DOM already updated
      │
      ▼
this.updateComplete resolves      ← promise observable from outside the component

All of these methods, except render(), receive the same argument: changedProperties, an object of type Map whose keys are the names of the properties that changed in this particular update, and whose values are the previous value of each one (not the new one: the new one is already directly available at this.propertyName). This map is the tool that allows, inside any of these hooks, distinguishing exactly what changed and reacting only to what matters, instead of needlessly recalculating everything on every update.

  1. shouldUpdate: the veto, mentioned for completeness

The first hook point in the cycle, shouldUpdate(changedProperties), decides whether the update should continue at all. If overridden and it returns false, Lit stops the cycle right there: neither willUpdate, nor render(), nor any later hook run for that particular update.

shouldUpdate(changedProperties) {
  // Illustrative example: ignore any update while
  // the card is in a temporary "read-only" state.
  if (this._bloqueadaTemporalmente) {
    return false;
  }
  return true; // the default value, inherited from LitElement, is always true
}

It is a performance-optimization tool for specific cases (avoiding costly renders when it's known in advance that the visual result wouldn't change), and it is not the focus of this lesson: <task-card> and the rest of TaskFlow's components don't need it for now. It's mentioned here only to complete the map from section 1 and to make clear exactly where in the cycle it sits, before moving on to the hooks that will actually be used.

  1. willUpdate: deriving state before rendering

willUpdate(changedProperties) runs right before render(), on every update (including the first one). It's the place specifically designed to derive or recalculate internal state from the property changes that have just occurred, so that calculation is already available by the time render() runs next, without having to repeat the same logic inside render() itself.

willUpdate(changedProperties) {
  if (changedProperties.has('prioridad')) {
    this._etiquetaPrioridad = this.prioridad >= 4 ? 'Alta' : 'Normal';
  }
}

An important detail: inside willUpdate it is safe to assign new reactive properties or plain instance fields, because render() hasn't been called yet in this pass; any assignment made here is folded into the same update already in progress, without triggering an additional full second cycle. This is exactly the opposite of what was warned about in module 2 regarding modifying properties inside render() itself (which can indeed cause update loops): willUpdate exists precisely to provide a safe place for this kind of derived calculation, before the template gets built.

changedProperties.has('name') is the usual pattern for avoiding unnecessary recalculation: first check whether the relevant property changed in this particular update, and only then run the corresponding derived calculation. Without that check, willUpdate would recalculate the same thing on every update, even ones that have nothing to do with that particular piece of data; for cheap calculations this poses no real problem, but it's a good habit as soon as the calculation starts to have some cost.

  1. render(): the point you already know

render() occupies, in this more complete cycle, exactly the place already known since module 2: it builds and returns the html template describing the component's current state. There's nothing new to add here except its relative position: it always runs after willUpdate (which has already had a chance to prepare any derived data) and always before the real DOM gets updated.

  1. firstUpdated: only once, after the first render

firstUpdated(changedProperties) runs only once in the component's entire life: right after the first update has already been applied to the real DOM. From this point in the cycle onward, unlike willUpdate, the Shadow Root's own DOM can reliably be queried, because it already reflects the result of render().

firstUpdated(changedProperties) {
  // The shadow root already contains the <article> returned by the first render().
  const articulo = this.shadowRoot.querySelector('article');
  console.log('Altura inicial de la tarjeta:', articulo.offsetHeight);
}

It is the place recommended by Lit's own documentation for tasks that only make sense to do once, and that need the DOM already built: measuring an element's real size, setting initial focus on a field, or initializing a third-party external library that needs a real DOM node to attach to. None of this fits in willUpdate (where the DOM hasn't been updated yet) nor would it make sense to repeat on every subsequent update, which is exactly what distinguishes firstUpdated from updated.

  1. updated: after every render, with the DOM already painted

updated(changedProperties) is firstUpdated's sibling that does run on every update, including the first one (in fact, on the first update, Lit calls firstUpdated first and then immediately updated; on subsequent ones, only updated). Like firstUpdated, it runs with the DOM already updated, so it's safe to query the Shadow Root with the certainty that it reflects the most recent result of render().

updated(changedProperties) {
  if (changedProperties.has('estado') && this.estado === 'hecha') {
    console.log(`La tarea "${this.titulo}" se ha marcado como hecha`);
  }
}

The typical use of updated is reacting to a change that has already been painted on screen: playing a one-off animation, syncing something with an external library that needs to know content has changed, or (as in the example in section 9) dispatching an event outward only when a certain condition becomes true. The check with changedProperties.has(...) is essential here too: without it, the logic inside updated would run on every update of the component regardless of what actually changed, which usually produces repeated effects unnecessarily (or, in the worst case, incorrectly).

  1. updateComplete: the promise that closes the cycle

this.updateComplete, already introduced in passing in module 2, is a special property of type Promise that resolves exactly when the full cycle of the current update finishes, that is, after updated has run. Unlike the four previous hooks, which are methods overridden inside the component's class, updateComplete is meant to be queried from outside, by any code that needs to know when a component has finished applying its most recent changes:

const tarjeta = document.querySelector('task-card');
tarjeta.estado = 'hecha';

await tarjeta.updateComplete;
// At this point, render(), firstUpdated (if applicable), and updated
// have already run, and the card's DOM reflects the new state.

Inside the component's own class, this.updateComplete is almost never needed: the internal hooks (willUpdate, updated, firstUpdated) already cover any need to react to the cycle from within. updateComplete is more useful in automated tests (waiting for a change to have been applied before making an assertion, something revisited in module 9) or in code external to Lit's own component tree that needs to synchronize with a specific component's update rhythm.

  1. Comparison table of the five hook points

Hook When does it run? Is the DOM already updated? How many times? Main use
shouldUpdate Before willUpdate No On every update Vetoing an entire update (return false)
willUpdate Right before render() No On every update, including the first Deriving/recalculating internal state from changed properties
render() Builds the template Not applicable On every update Returning the html that describes the current state
firstUpdated After applying the first render() to the DOM Yes Only once Measuring the DOM, focusing a field, initializing external libraries
updated After applying each render() to the DOM Yes On every update, including the first Reacting to changes already painted on screen
updateComplete Resolves when the cycle finishes Yes One promise per cycle, queryable from outside Waiting, from outside the component, for an update to finish

A simple way to remember when to use willUpdate versus updated, which nicely summarizes the whole lesson's criterion: if the logic needs the value of a property to compute another derived value that render() is going to use, it goes in willUpdate; if the logic needs the already-rendered DOM, or produces an effect outward from the rendering process itself, it goes in updated (or in firstUpdated, if it should only happen once).

  1. Applying willUpdate to <task-card>: urgency derived from the date

The previous lesson added to <task-card> a "close to due" warning, periodically recalculated with a timer, because the mere passage of time can trigger it without any property changing. There is, however, a related but distinct problem: when a new fechaLimite is assigned to a card (for example, if TaskFlow allows editing the deadline of an already-created task in the future), it's useful to immediately recalculate whether that new date falls within a short-term "urgent" range, without waiting for the timer's next tick and without duplicating the calculation directly inside render(). This is exactly the use case willUpdate handles better than any other hook: reacting to a specific property change, not to the passage of time.

class TaskCard extends LitElement {
  static properties = {
    // ...rest of the properties unchanged...
    fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
    cercaDeVencer: { state: true },
  };

  willUpdate(changedProperties) {
    if (changedProperties.has('fechaLimite')) {
      // Recalculate immediately, without waiting for the previous lesson's
      // timer's next tick, as soon as the deadline
      // changes via direct property assignment.
      this.cercaDeVencer = this._calcularSiCercaDeVencer();
    }
  }

  // _calcularSiCercaDeVencer(), connectedCallback(), and disconnectedCallback()
  // remain exactly the same as in the previous lesson.

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <h3>${this.titulo}</h3>
        ${this.renderInsigniaEstado()}
        ${this.renderSelectorEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
        ${this.cercaDeVencer ? html`<p class="aviso">⏰ Está a punto de vencer</p>` : ''}
        ${this.expandida
          ? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
          : ''}
      </article>
    `;
  }
}

Notice how willUpdate and the timer in connectedCallback coexist without stepping on each other: both write to the same state property, cercaDeVencer, but they react to different, complementary triggers. The timer covers the case where nothing changes on the card, but the clock keeps ticking (a task can become urgent without anyone touching it); willUpdate covers the case where the deadline changes explicitly, and it's worth reflecting that immediately in the same update that's already going to happen because of that property change, instead of waiting until the interval's next tick (which could take up to a minute to fire, given the previous lesson's configuration). Neither mechanism replaces the other: they complement each other, each covering a different trigger for the same derived data.

It's worth stressing, to close this section, why this logic doesn't live directly inside render(): if render() called this._calcularSiCercaDeVencer() on every execution, the result would be functionally equivalent, but that value would be recalculated on every render (including ones that have nothing to do with fechaLimite, such as a simple change to prioridad), and the ability to explicitly check with changedProperties.has('fechaLimite') whether it's even worth recalculating anything would be lost. willUpdate allows concentrating that decision in a single place, run only when appropriate, leaving render() with its original responsibility: reading the already-computed this.cercaDeVencer and translating it into HTML, without deciding anything on its own.

Common Mistakes and Tips

  • Confusing willUpdate with updated: they are opposites regarding the state of the DOM: inside willUpdate the DOM doesn't yet reflect this update's changes (which is why it's safe to derive state there, but useless to try to read the already-updated DOM); inside updated, the DOM is already current (which is why it's the right place to read it or to produce outward effects, but it's already too late for any property change made there to be folded into this same render() pass without triggering a second update).
  • Forgetting changedProperties.has(...) inside willUpdate or updated: without that check, the logic runs on every update, without distinguishing which ones are actually relevant; for calculations with some cost, or for effects that shouldn't repeat without reason (like the dispatchEvent in section 9), this omission produces unnecessary work or duplicated behavior.
  • Trying to manipulate the Shadow Root's DOM inside willUpdate: at that point in the cycle, as explained, the DOM still corresponds to the previous update; any querySelector run there might return a node that's about to disappear or change as soon as render() finishes. That kind of DOM access belongs in firstUpdated or updated.
  • Using firstUpdated for something that should repeat on every update: if the logic depends on data that changes over time (like titulo or estado), firstUpdated would only run once, with the initial values, and would remain forever out of date; the correct hook in that case is updated.

Exercises

  1. Add to <task-card> an updated(changedProperties) hook that, only the first time cercaDeVencer transitions from false to true (and not on every subsequent update while it remains true), dispatches a custom event tarea-proxima-a-vencer with bubbles: true and composed: true. Hint: changedProperties.get('cercaDeVencer') contains the property's previous value, exactly what's needed to distinguish this specific transition.
  2. Explain, based on section 3, why it would be a mistake to write the logic from the previous exercise inside willUpdate instead of inside updated.
  3. A teammate proposes removing willUpdate from <task-card> and, instead, calling this._calcularSiCercaDeVencer() directly from inside render() whenever the value is needed. Explain, drawing on the closing of section 9, at least one concrete disadvantage of that approach compared to using willUpdate.

Solutions

updated(changedProperties) {
  if (changedProperties.has('cercaDeVencer')) {
    const eraCercaDeVencer = changedProperties.get('cercaDeVencer');
    if (!eraCercaDeVencer && this.cercaDeVencer) {
      this.dispatchEvent(
        new CustomEvent('tarea-proxima-a-vencer', {
          detail: { titulo: this.titulo },
          bubbles: true,
          composed: true,
        })
      );
    }
  }
}

changedProperties.get('cercaDeVencer') returns the value the property had before this update (false, if this is the transition worth detecting); comparing that previous value against this.cercaDeVencer (already updated to true) isolates exactly the instant the card enters the urgency window, without repeating the warning on later updates where cercaDeVencer remains true without having changed.

  1. willUpdate runs before the DOM reflects the update in progress, and even before render() has run. Dispatching an outward event there would be premature in the conceptual sense of the cycle: the very principle of updated, noted in section 6, is precisely to serve as the place for effects that must happen after the change has already been applied visually; an event like tarea-proxima-a-vencer, meant for other components to react to an already-consummated fact, fits that principle, not the one for willUpdate, reserved for preparing derived data that render() itself is still going to use.

  2. Calling this._calcularSiCercaDeVencer() directly inside render() would recalculate that value on every execution of render(), including ones that have absolutely nothing to do with fechaLimite (for example, a change to prioridad or expandida), wasting work on a calculation that, most of the time, would give exactly the same result it already had. In addition, the ability to react selectively only when fechaLimite truly changes (via changedProperties.has('fechaLimite')) would be lost, which is exactly the advantage willUpdate provides over unconditionally repeating the calculation inside the template itself.

Conclusion

This lesson has completed the map of Lit's own update cycle: shouldUpdate as an optional veto, willUpdate for deriving state before rendering, render() at the already-known point, firstUpdated for what should only happen once with the DOM already built, updated for reacting to every already-painted update, and updateComplete as the promise that lets external code wait for that cycle to finish. <task-card> now uses willUpdate to recalculate its date-based urgency as soon as fechaLimite changes, without duplicating that logic inside render(), complementing the previous lesson's timer instead of replacing it.

All of this timer logic, however, still lives directly inside the TaskCard class, mixed in with the rest of its behavior. If TaskFlow needed, further down the line, the same kind of proximity-to-deadline warning in a different component (for example, a future summary of urgent tasks inside <task-board> itself), it would have to duplicate connectedCallback, disconnectedCallback, and _calcularSiCercaDeVencer() entirely. The next lesson presents the tool Lit offers precisely to avoid that duplication: reactive controllers.

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