En la lección anterior <task-card> ganó cuatro propiedades reactivas públicas: titulo, estado, prioridad y urgente. Las cuatro tienen algo en común: forman parte de los datos de la tarea, vienen "de fuera" (de quien use el componente) y tiene sentido que se puedan establecer como atributos HTML. Pero no todo lo que necesita reactividad dentro de un componente encaja en ese perfil. Esta lección presenta el estado interno privado, declarado con state: true (o el decorador @state), y lo usa para añadir a <task-card> la capacidad de expandirse y mostrar más detalle al hacer clic, sin que ese detalle expandido forme parte de la API pública del componente.

Contenido

  1. Dos tipos de dato reactivo: propiedad pública y estado interno
  2. Declarar estado interno con state: true
  3. El decorador @state como alternativa
  4. Casos de uso típicos del estado interno
  5. Añadiendo expandida a <task-card>
  6. Una mención de paso: el manejo del clic

  1. Dos tipos de dato reactivo: propiedad pública y estado interno

Las propiedades vistas en la lección anterior (titulo, estado, prioridad, urgente) comparten una característica de fondo: forman parte de la API pública del componente. Son datos que otro código —el componente padre, o quien escriba el HTML donde se usa <task-card>— necesita poder establecer desde fuera, ya sea mediante un atributo HTML o mediante una asignación de JavaScript. Por eso tiene sentido que Lit les cree, por defecto, un atributo HTML asociado, como se explicó en el apartado 3 de la lección anterior.

Pero un componente, a medida que gana comportamiento propio, a menudo necesita guardar datos que no tienen ese perfil: datos que solo le importan a él mismo, para gestionar su propio comportamiento interno, y que nadie de fuera del componente debería ni necesitar leer ni, sobre todo, necesitar establecer. Un ejemplo típico, que es justamente el que se va a construir en esta lección: si una tarjeta de tarea está "expandida" (mostrando más detalle) o "contraída" (mostrando solo el resumen). Ese dato no describe la tarea en sí —una tarea no "es" expandida o contraída, es la tarjeta que la representa la que tiene ese estado visual—, y no tiene ningún sentido que alguien escriba <task-card expandida="true"> en su HTML esperando controlar ese detalle desde fuera.

Para este segundo tipo de dato, Lit ofrece el estado interno (en inglés, internal reactive state), que en static properties se declara con la opción state: true en lugar de type. Un estado interno sigue siendo completamente reactivo —cambiar su valor dispara una actualización exactamente igual que con una propiedad pública—, pero no genera ningún atributo HTML, y Lit lo marca, tanto en su documentación como en herramientas de desarrollo, como una pieza que pertenece a la implementación interna del componente, no a su interfaz pública.

Aspecto Propiedad pública (type: String, etc.) Estado interno (state: true)
¿Es reactiva?
¿Genera atributo HTML? Sí, por defecto No, nunca
¿Forma parte de la API del componente? No
¿Quién la establece habitualmente? El código que usa el componente (padre, HTML) El propio componente, desde dentro
Ejemplo en <task-card> titulo, estado, prioridad, urgente expandida

  1. Declarar estado interno con state: true

La sintaxis es una variación mínima de lo ya visto en la lección anterior: en lugar de { type: Boolean }, se escribe { state: true }.

import { LitElement, html } from 'lit';

class TaskCard extends LitElement {
  static properties = {
    titulo: { type: String },
    expandida: { state: true },
  };

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

  render() {
    return html`
      <article>
        <h3>${this.titulo}</h3>
        ${this.expandida ? html`<p>Detalle adicional de la tarea...</p>` : ''}
      </article>
    `;
  }
}

Fíjate en que expandida sigue asignándose en el constructor, igual que cualquier otra propiedad reactiva, y sigue leyéndose en render() con this.expandida, exactamente igual que titulo. La única diferencia real está en la declaración: { state: true } en lugar de { type: Boolean }. Este pequeño cambio en la declaración es suficiente para que Lit no genere ningún atributo expandida en el HTML del elemento, aunque la propiedad JavaScript this.expandida siga existiendo y siga siendo perfectamente reactiva.

Una convención habitual, aunque no obligatoria, en el código Lit real es anteponer un guion bajo al nombre de los campos de estado interno más delicados, o al menos evitar cualquier documentación pública que invite a leerlos o escribirlos desde fuera del componente; Lit no impone ninguna restricción técnica que impida acceder a elemento.expandida desde fuera (JavaScript no tiene un verdadero mecanismo de "privado" para campos declarados así, más allá de los campos con # del propio lenguaje, que Lit no usa aquí), pero la intención de diseño es clara: es un detalle interno, y tratarlo como tal en el resto del código evita acoplar el comportamiento externo del componente a un dato que podría cambiar de nombre o de forma sin previo aviso.

  1. El decorador @state como alternativa

Igual que con @property en la lección anterior, existe una sintaxis equivalente con decoradores para el estado interno:

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

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

  @state()
  expandida = false;

  render() {
    return html`
      <article>
        <h3>${this.titulo}</h3>
        ${this.expandida ? html`<p>Detalle adicional de la tarea...</p>` : ''}
      </article>
    `;
  }
}

El decorador @state() no admite las mismas opciones que @property() (no tendría sentido pasarle type o attribute, ya que un estado interno nunca tiene atributo asociado); simplemente marca el campo como estado reactivo interno. El resultado en tiempo de ejecución es idéntico al de { state: true } en static properties.

  1. Casos de uso típicos del estado interno

Antes de aplicar expandida a <task-card>, conviene tener claro el patrón general de cuándo usar estado interno en lugar de una propiedad pública. Algunos ejemplos típicos, más allá del que se va a construir en esta lección:

  • Un contador interno: por ejemplo, cuántas veces se ha pulsado un botón dentro del propio componente, sin que ese conteo forme parte de los datos que describe el componente hacia fuera.
  • Un flag de "expandido/colapsado": exactamente el caso de <task-card> en esta lección; un <details> personalizado, un menú desplegable, o cualquier componente con una sección que se muestra u oculta según una interacción del propio componente.
  • El estado de una petición en curso: un campo como cargando (true/false) mientras un componente espera una respuesta de red, que solo le interesa a él mismo para decidir qué mostrar mientras tanto (un texto "Cargando..." o un indicador visual).
  • Un valor intermedio calculado a partir de propiedades públicas: por ejemplo, si un componente recibe una lista larga como propiedad pública pero internamente solo muestra una página de resultados a la vez, el número de página actual sería un buen candidato a estado interno.

La pregunta que conviene hacerse siempre para decidir entre propiedad pública y estado interno es: ¿tiene sentido que alguien, desde fuera del componente, quiera leer o establecer este valor como parte de cómo usa el componente? Si la respuesta es sí, es una propiedad pública. Si el valor solo tiene sentido como detalle de implementación del propio componente, es estado interno.

  1. Añadiendo expandida a <task-card>

Con el criterio del apartado anterior, expandida encaja claramente como estado interno: describe si la tarjeta, como pieza de interfaz, está mostrando su detalle ampliado, no un dato de la tarea en sí. Retoma src/components/task-card.js tal como quedó en la lección anterior y añade el nuevo estado:

import { LitElement, html } from 'lit';

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

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

  alternarExpandida() {
    this.expandida = !this.expandida;
  }

  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 @click="${this.alternarExpandida}">
        <h3>${this.titulo}</h3>
        ${this.renderInsigniaEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
        ${this.expandida
          ? html`
              <div class="detalle">
                <p>Estado interno: la tarjeta está expandida.</p>
                <p>Aquí, más adelante en el curso, podría mostrarse una descripción larga, un histórico de cambios, o comentarios de la tarea.</p>
              </div>
            `
          : ''}
      </article>
    `;
  }
}

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

El método alternarExpandida() invierte el valor booleano de this.expandida con el operador de negación (!this.expandida); como expandida es un estado interno reactivo, esa asignación pasa por el mismo mecanismo de setter explicado en la lección anterior y dispara una actualización, exactamente igual que con cualquier propiedad pública. La plantilla usa el operador ternario visto en el módulo 2 para mostrar u ocultar el bloque de detalle según el valor de this.expandida.

  1. Una mención de paso: el manejo del clic

Es posible que te haya llamado la atención la sintaxis @click="${this.alternarExpandida}" dentro de la etiqueta <article> del ejemplo anterior: es la forma en que Lit permite escuchar eventos del DOM directamente desde una plantilla, con un prefijo @ seguido del nombre del evento y, entre comillas, una referencia a la función que debe ejecutarse al producirse ese evento (en este caso, un evento click del navegador sobre el propio <article>).

Este curso trata los eventos en profundidad en el módulo 5, "Eventos y Comunicación entre Componentes", donde se explicará esta sintaxis con detalle, cómo despachar eventos personalizados propios de un componente (por ejemplo, para que <task-card> avise a <task-list> de que el usuario quiere marcar una tarea como completada), y cómo gestionar correctamente el valor de this dentro del método manejador. Aquí solo se usa lo mínimo indispensable —un evento nativo del navegador (click) sobre un método que ya no recibe ningún argumento del propio evento— para poder disparar el cambio de expandida y así completar el ejemplo del estado interno; no hace falta profundizar más en este punto del curso.

Lo importante para esta lección es constatar que, sea cual sea el origen del cambio (un clic del usuario, una respuesta de red, un temporizador...), el mecanismo de reactividad es exactamente el mismo que ya conoces: se asigna un nuevo valor a una propiedad o a un estado reactivo, y Lit se encarga de programar la actualización correspondiente.

Errores Comunes y Consejos

  • Declarar como propiedad pública un dato que en realidad es estado interno: si se declara expandida con { type: Boolean } en lugar de { state: true }, el componente seguirá funcionando (la reactividad es la misma), pero se generará innecesariamente un atributo HTML expandida que forma parte, sin quererlo, de la API pública del componente, invitando a un uso que no tiene sentido (<task-card expandida></task-card>).
  • Esperar que state: true impida el acceso desde fuera: como se explicó en el apartado 2, state: true es una convención de diseño respaldada por Lit (sin atributo, marcada como interna en herramientas de desarrollo), pero no un mecanismo de privacidad real de JavaScript. Nada impide técnicamente que código externo lea o escriba elemento.expandida; simplemente no se documenta ni se espera que se use así.
  • Olvidar inicializar el estado interno en el constructor: exactamente el mismo error que con las propiedades públicas del módulo anterior; si no se asigna un valor inicial a this.expandida en el constructor, su valor será undefined en el primer renderizado, lo que puede producir comportamientos inesperados en las expresiones condicionales de la plantilla.
  • Meter demasiada lógica de negocio en el estado interno de un componente de presentación: un componente como <task-card> puede tener perfectamente su propio estado visual (expandida), pero si empieza a acumular estado interno relacionado con datos de negocio (por ejemplo, una copia propia de la lista completa de tareas), es una señal de que esa responsabilidad probablemente debería vivir en un componente padre y llegar como propiedad pública, no duplicarse como estado interno.

Ejercicios

  1. Añade a <task-card> un nuevo estado interno vecesExpandida, de tipo numérico, inicializado a 0, que se incremente en uno cada vez que se llama a alternarExpandida() (independientemente de si el resultado es expandir o contraer). Muéstralo en la plantilla solo con fines de depuración, por ejemplo como <p>Expandida ${this.vecesExpandida} veces</p>.
  2. Explica, apoyándote en la tabla del apartado 1, por qué no tendría sentido declarar titulo como { state: true } en lugar de { type: String }.
  3. Imagina un componente <user-avatar> (uno de los componentes previstos para TaskFlow más adelante en el curso) que muestra la imagen de un usuario y, mientras esa imagen todavía no ha terminado de cargar, un color de fondo de relleno. Decide, razonando con el criterio del apartado 4, si el dato "la imagen ya ha terminado de cargar" (true/false) debería ser una propiedad pública o un estado interno.

Soluciones

static properties = {
  // ...propiedades anteriores...
  expandida: { state: true },
  vecesExpandida: { state: true },
};

constructor() {
  super();
  // ...
  this.expandida = false;
  this.vecesExpandida = 0;
}

alternarExpandida() {
  this.expandida = !this.expandida;
  this.vecesExpandida = this.vecesExpandida + 1;
}

Como ambas asignaciones ocurren dentro de la misma función síncrona, Lit las agrupa (según lo explicado en la lección del ciclo de renderizado del módulo 2) y ejecuta render() una sola vez con los dos valores ya actualizados.

  1. titulo es un dato que describe la propia tarea y que necesita poder establecerse desde fuera del componente: quien use <task-card> (ya sea escribiendo el atributo HTML directamente o asignando la propiedad desde <task-list>) necesita indicar qué título mostrar. Si se declarara como estado interno (state: true), Lit no generaría ningún atributo HTML asociado, y sería imposible establecer el título mediante <task-card titulo="...">; solo quedaría la vía de asignarlo por JavaScript tras crear el elemento, lo cual limita innecesariamente cómo se puede usar el componente.

  2. Es un buen candidato a estado interno: el hecho de que la imagen concreta ya haya terminado de cargar es un detalle de implementación de cómo <user-avatar> gestiona su propia carga de recursos, no un dato que describa al usuario ni algo que quien use el componente necesite establecer desde fuera (nadie escribiría <user-avatar imagen-cargada="true"> esperando controlar ese aspecto). Encaja con el criterio del apartado 4: solo le importa al propio componente, para decidir internamente qué mostrar mientras tanto.

Conclusión

En esta lección has aprendido a distinguir entre propiedades públicas, que forman parte de la API de un componente y suelen tener un atributo HTML asociado, y estado interno privado, declarado con state: true (o @state), reactivo igual que cualquier propiedad pero sin generar ningún atributo y sin pertenecer a la interfaz externa del componente. Has aplicado esta distinción a <task-card>, que ahora puede expandirse y contraerse mediante un estado interno expandida, alternado con un manejador de clic mínimo cuyo detalle completo se reserva para el módulo 5.

Hasta ahora, todas las propiedades declaradas —tanto públicas como internas— han usado los tipos más sencillos: String, Number y Boolean. En la siguiente lección, "Tipos de Propiedades y Conversores Personalizados", verás el catálogo completo de tipos soportados de serie por Lit, entenderás con detalle cómo convierte entre el texto de un atributo HTML y el valor JavaScript de la propiedad, y aprenderás a definir un conversor propio para un tipo de dato que Lit no sabe interpretar de forma nativa, aplicándolo a una nueva propiedad fechaLimite en <task-card>.

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow

© Copyright 2026. Todos los derechos reservados