The last lesson of the previous module pinpointed the problem precisely: <task-card> and <task-list> use plain instance fields (this.titulo, this.estado, this.tareas...), and those fields don't trigger any update because Lit has no mechanism installed to watch them. This lesson solves exactly that gap: you'll learn to declare truly reactive properties with static properties, you'll understand what happens internally when a property is declared this way, and you'll convert <task-card> so that titulo, estado, prioridad, and urgente stop being loose fields and become reactive properties recognized by Lit.

Contents

  1. What exactly it means for a property to be "reactive"
  2. Declaring properties with static properties
  3. Configuration options: type, attribute, reflect
  4. The alternative with the @property decorator
  5. Converting <task-card> to real reactive properties
  6. Using <task-card> from HTML (attributes) and from JavaScript (properties)
  7. What happens under the hood: the accessors mechanism

  1. What exactly it means for a property to be "reactive"

Section 1 of the last lesson of module 2 explained that render() isn't called on its own: it needs a trigger, and that usual trigger is a change in a reactive property. Now it's time to pin down exactly what that word, "reactive," means when applied to a property of a Lit component.

A reactive property is a class property that Lit knows explicitly because it has been declared as such (with static properties, or with the @property decorator, covered in section 4). When declared, Lit installs a special mechanism on it —a getter and a setter, as detailed in section 7— that lets it automatically detect when its value changes. The moment that change is detected, Lit schedules a component update, following exactly the asynchronous, batched process explained in the rendering cycle lesson.

The difference from a plain instance field, like the ones used throughout module 2, is exactly that: an instance field (this.titulo = 'algo' and nothing more) is an ordinary JavaScript assignment, invisible to Lit. A reactive property is an assignment that goes through a Lit mechanism capable of reacting to it. The property name can be identical in both cases (this.titulo); what changes is whether that property has been previously declared in static properties (or with @property) or not.

  1. Declaring properties with static properties

The most direct way to declare reactive properties, with no need for any additional build step (as already hinted at in the anatomy lesson of module 1), is a static class field called properties, which holds an object: one key per property to declare, and a value describing how that property should behave.

import { LitElement, html } from 'lit';

class TaskCard extends LitElement {
  static properties = {
    titulo: { type: String },
    estado: { type: String },
  };

  constructor() {
    super();
    this.titulo = 'Tarea sin título';
    this.estado = 'pendiente';
  }

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

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

Two important details in this example:

  • static properties is declared once, outside any method, as a static field of the class (using modern JavaScript class field syntax, available without transpilation in any modern browser). It is not a method, nor does it run each time something changes: it is simply the list of "what properties exist and what they're like," which Lit reads a single time, when the class is defined.
  • The initial value of each property is still assigned in the constructor, exactly as with the instance fields of the previous module. static properties doesn't assign any value on its own: it only declares the property's existence and its configuration. It's the constructor's responsibility (after the mandatory call to super()) to give it a sensible initial value.

From this point on, this.titulo and this.estado are no longer ordinary instance fields: they are reactive properties. Any later assignment (elemento.titulo = 'Otro título', whether from inside the class or from outside) will go through Lit's mechanism and trigger an update.

  1. Configuration options: type, attribute, reflect

The configuration object for each property ({ type: String } in the previous examples) accepts several keys. The three most relevant at this point in the course are type, attribute, and reflect; the first two are covered in detail here, and reflect is revisited in depth in the lesson "Attributes vs Properties and Reflection" later in this same module, so a brief introduction suffices here.

Option What it's for Default value
type Tells Lit what data type the property has (String, Number, Boolean, Array, Object), so it can correctly convert between the HTML attribute (always text) and the property's JavaScript value String if not specified
attribute Controls whether the property has an associated HTML attribute, and under what name. By default, Lit automatically creates an attribute with the same name as the property, in lowercase true (same name, lowercase)
reflect Controls whether, besides reading the attribute into the property, Lit also writes the attribute back when the property changes from JavaScript false

An example with all three options at once, on a fictitious property prioridadAlta:

static properties = {
  prioridadAlta: {
    type: Boolean,
    attribute: 'prioridad-alta', // the HTML attribute will be called "prioridad-alta", not "prioridadalta"
    reflect: true,               // changing the property from JS also updates the attribute
  },
};

Notice the attribute name: since HTML doesn't distinguish uppercase from lowercase in attribute names, a property named in camelCase like prioridadAlta would, if left at its default, need to become prioridadalta (all run together, lowercase), which is hard to read. That's why, for properties with compound names, it's common to explicitly specify the desired attribute name in kebab-case format (prioridad-alta), as done here.

If you don't want a property to have any associated attribute at all —for example, because it only makes sense to assign it from JavaScript, never from HTML—, you can disable it completely with attribute: false. In that case, the property remains fully reactive (Lit keeps watching its changes from JavaScript), but there's no way to set its initial value through an HTML attribute.

  1. The alternative with the @property decorator

As already noted in the anatomy lesson of module 1, Lit offers an alternative syntax, equivalent in behavior, based on TypeScript decorators or JavaScript with Babel:

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('task-card')
class TaskCard extends LitElement {
  @property({ type: String })
  titulo = 'Tarea sin título';

  @property({ type: String })
  estado = 'pendiente';

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

The @property decorator receives exactly the same configuration object as an entry in static properties (type, attribute, reflect), and the initial value is assigned directly alongside the field declaration, with no need for an explicit constructor. The runtime result is identical to that of static properties: both syntaxes end up generating the same internal reactive-properties mechanism.

This course keeps using static properties as its main style, since it requires no additional build step (as explained in the environment setup lesson of module 1), but it's important to recognize the decorator syntax because it appears very often in Lit's official documentation and in real projects that use TypeScript.

  1. Converting <task-card> to real reactive properties

With the theory already covered, it's time to take the step announced at the close of module 2: converting the instance fields of <task-card> (titulo, estado, prioridad, urgente) into reactive properties declared with static properties. Pick up the file src/components/task-card.js as it stood at the end of module 2, with the status badge and the urgency notice, and add the property declaration:

import { LitElement, html } from 'lit';

class TaskCard extends LitElement {
  static properties = {
    titulo: { type: String },
    estado: { type: String },
    prioridad: { type: Number },
    urgente: { type: Boolean },
  };

  constructor() {
    super();
    this.titulo = 'Tarea sin título';
    this.estado = 'pendiente'; // 'pendiente' | 'en-progreso' | 'hecha'
    this.prioridad = 3;
    this.urgente = false;
  }

  renderInsigniaEstado() {
    if (this.estado === 'hecha') {
      return html`<span class="insignia insignia--hecha">✓ Hecha</span>`;
    }
    if (this.estado === 'en-progreso') {
      return html`<span class="insignia insignia--progreso">◐ En progreso</span>`;
    }
    return html`<span class="insignia insignia--pendiente">○ Pendiente</span>`;
  }

  render() {
    return html`
      <article>
        <h3>${this.titulo}</h3>
        ${this.renderInsigniaEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
      </article>
    `;
  }
}

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

Compare this code with that of the "Conditional Rendering" lesson of module 2: the template (render(), renderInsigniaEstado()) hasn't changed a single line. The only new thing is the static properties block, which explicitly declares the four properties, and the addition of prioridad to the template (previously it wasn't shown anywhere, since there was no point displaying a value that couldn't change reactively). This is an idea worth keeping in mind: adding reactivity doesn't require rewriting the template; render() still reads this.titulo, this.estado, etc., exactly as before. What changes is that, now, writing to those properties does cause render() to run again.

  1. Using <task-card> from HTML (attributes) and from JavaScript (properties)

With the properties already declared, <task-card> can receive its values in two different ways, and it's important to distinguish them clearly.

From HTML, via attributes (always as text, which is why this only makes sense for titulo and estado, whose declared type is String; for prioridad, of type Number, and urgente, of type Boolean, Lit takes care of converting the attribute text to the declared type, as detailed in the next lesson):

<task-card titulo="Preparar la demo del sprint" estado="en-progreso"></task-card>

From JavaScript, via the DOM property (with any data type, without going through text):

const tarjeta = document.querySelector('task-card');
tarjeta.titulo = 'Revisar el PR de autenticación';
tarjeta.estado = 'pendiente';
tarjeta.prioridad = 5;
tarjeta.urgente = true;

And, as already seen in the expressions and interpolation lesson of module 2, a property can also be assigned directly inside an html template using the dot prefix:

html`<task-card .titulo="${'Desplegar a producción'}" .prioridad="${5}"></task-card>`

With the properties declared as reactive, any of these three ways of assigning a value —an HTML attribute when creating the element, a JavaScript property at any moment, or a dot interpolation inside an html template— now triggers a real update: the next time Lit runs render(), the card will show the new values. This is, precisely, the missing piece throughout module 2.

  1. What happens under the hood: the accessors mechanism

To finish demystifying the "magic" of reactive properties, it's worth understanding, at a reasonable level of detail, what Lit actually does when it processes static properties. For each declared property, Lit installs on the class prototype a pair of special JavaScript functions called getter and setter (via Object.defineProperty, a standard language feature, not specific to Lit), instead of letting titulo be a plain field.

In simplified form, it's as if Lit automatically generated something equivalent to this for the titulo property:

// Simplification of what Lit does internally for each declared property
get titulo() {
  return this._titulo;
}

set titulo(valorNuevo) {
  const valorAnterior = this._titulo;
  this._titulo = valorNuevo;
  this.requestUpdate('titulo', valorAnterior); // the trigger seen in module 2
}

When the code writes this.titulo = 'Nuevo título', no field is actually being assigned directly: the setter that Lit installed is being called, which stores the new value in an internal field, and then calls this.requestUpdate(...), exactly the same low-level method introduced in the last lesson of module 2. This confirms the idea anticipated back then: reactive properties are not a mechanism separate from requestUpdate(), but a convenience layer built on top of it, which invokes it automatically at the right moment.

You don't need to memorize this internal detail to work with Lit day to day, but knowing it helps reason about less obvious cases: for example, it explains why mutating an array or object stored in a reactive property (this.tareas.push(nuevaTarea)) doesn't trigger any update on its own, while reassigning the whole property does (this.tareas = [...this.tareas, nuevaTarea]): the setter only runs when there's a real assignment to this.tareas, not when a method that modifies the existing array's contents is called without reassigning it. This distinction will be revisited in detail later in the course, when working with collections that change over time.

Common Mistakes and Tips

  • Forgetting to declare a property in static properties and expecting it to be reactive: if you assign this.algo = valor without having declared algo in static properties (nor with @property), that assignment is a plain instance field, invisible to Lit, exactly as explained in module 2. The symptom is always the same: the value changes internally, but the screen doesn't update.
  • Assigning the initial value inside static properties instead of in the constructor: static properties only declares the property's configuration (type, attribute, reflection); it is not the place to set the default value. The initial value is assigned in the constructor, after super(), as in every example in this lesson.
  • Mutating a reactive array or object and expecting it to trigger an update: as explained in section 7, this.coleccion.push(elemento) doesn't go through the property's setter, so Lit doesn't detect it. You need to reassign the whole property (for example, with the spread operator [...this.coleccion, elemento]) for the change to be visible to Lit.
  • Confusing the property name with the attribute name on properties with camelCase: as seen in section 3, a property prioridadAlta without explicit attribute configuration generates by default an attribute prioridadalta (all run together), not prioridad-alta. If you want a kebab-case attribute, you must specify it explicitly.

Exercises

  1. Add to <task-card> a new reactive property called asignadoA, of type String, with initial value 'Sin asignar', and show it in the template inside a paragraph <p>Asignada a: ${this.asignadoA}</p>. Verify, from the browser console, that elemento.asignadoA = 'Ana' updates the screen.
  2. Declare in <task-card> a property completadaEn, of type String, whose HTML attribute is explicitly named completada-en (using the attribute option seen in section 3). Write the example HTML you'd use to set that property from a <task-card> tag.
  3. Based on section 7, explain in your own words why this.tareas.push(nuevaTarea) on a reactive property tareas of type Array doesn't cause any visible update, and what line of code you'd need to write instead to make it happen.

Solutions

static properties = {
  titulo: { type: String },
  estado: { type: String },
  prioridad: { type: Number },
  urgente: { type: Boolean },
  asignadoA: { type: String },
};

constructor() {
  super();
  this.titulo = 'Tarea sin título';
  this.estado = 'pendiente';
  this.prioridad = 3;
  this.urgente = false;
  this.asignadoA = 'Sin asignar';
}

render() {
  return html`
    <article>
      <h3>${this.titulo}</h3>
      ${this.renderInsigniaEstado()}
      <p>Prioridad: ${this.prioridad}</p>
      <p>Asignada a: ${this.asignadoA}</p>
      ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
    </article>
  `;
}

Running elemento.asignadoA = 'Ana' from the console, Lit detects the change through the setter installed on asignadoA (as explained in section 7) and schedules an update; after that update, the paragraph will show "Asignada a: Ana".

static properties = {
  // ...previous properties...
  completadaEn: { type: String, attribute: 'completada-en' },
};
<task-card titulo="Revisar el PR" completada-en="2026-07-01"></task-card>
  1. this.tareas.push(nuevaTarea) modifies the array that this.tareas already points to, but doesn't perform any assignment on the tareas property itself; since the setter installed by Lit (seen in section 7) only fires when there's a real assignment (this.tareas = ...), push goes completely unnoticed by Lit and no update is scheduled. For the change to be detected, you'd need to reassign the whole property with a new array, for example this.tareas = [...this.tareas, nuevaTarea];, so that the setter does run.

Conclusion

In this lesson you've learned to declare truly reactive properties with static properties, understanding the role of its configuration options (type, attribute, reflect) and the equivalent syntax with the @property decorator. You've converted <task-card> so that titulo, estado, prioridad, and urgente are real reactive properties, able to receive values both from HTML attributes and from JavaScript, and you've understood, at the level of getters and setters, why that makes them trigger automatic updates where nothing used to happen before.

However, not every property of a component should be public in this way. <task-card> will need, in the next lesson, an internal piece of data —whether the card is expanded or not— that makes no sense to expose as part of its public API or as an HTML attribute. That's the content of the next lesson, "Internal State with @state," where you'll learn the difference between a public property and a component's private state.

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