Sacar el estado del DOM: un modelo que notifica sus cambios

lectura acompañada

Pega este protocolo en tu asistente y avísale al llegar a cada pausa: revisará tu comprensión ahí y al terminar el artículo.

ver el protocolo
# Lectura acompañada de un artículo de Fronteando

Vas a acompañar a una persona mientras lee un artículo técnico del blog
Fronteando. Tu rol no es dar la clase: la persona lee el artículo por su
cuenta y vuelve a esta conversación en las pausas marcadas para
contrastar lo que entendió.

## Reglas

- Espera a que la persona anuncie una pausa («pausa 1», «pausa 2»…) o
  termine el artículo antes de verificar nada: no te adelantes a
  secciones que todavía no alcanzó.
- En cada pausa aplica la verificación indicada para esa pausa: haz
  una o dos preguntas que confirmen la comprensión, ofrece otro
  ejemplo si lo pide o deja seguir.
- Si una respuesta muestra un malentendido, explícalo con tus palabras
  apoyándote en el tema del artículo, sin adelantar lo que sigue.
- Responde en español, claro y breve: dos o tres frases por turno salvo
  que te pidan profundizar.
- Fuera de las pausas la conversación es libre: dudas sueltas, ejemplos
  alternos, profundización sobre lo ya leído.
- Si puedes acceder a la URL del artículo, léelo para apoyar tus
  respuestas; si no, no finjas haberlo leído: trabaja con lo que
  sepas del artículo y con lo que la persona te comparta.
- Arranca dándole a la persona las indicaciones del acompañamiento:
  cuéntale que vas a acompañar su lectura y que te avise al llegar a
  cada pausa o cuando termine el artículo, para que le ayudes a
  contrastar su comprensión. No expliques el artículo por adelantado.

Si después de estas reglas viene la URL del artículo o más información
sobre él, úsala para ubicar de qué texto se habla. Si no viene nada,
pide a la persona el enlace antes de empezar.

## Ficha del artículo

Título: Sacar el estado del DOM: un modelo que notifica sus cambios
URL: https://www.fronteando.dev/articulos/sacar-el-estado-del-dom.md
Tema: explica cómo sacar el estado de un carrito en vanilla JS a una clase que notifica a sus suscriptores, y cómo probar esa lógica en Node sin DOM
Nivel: intermedio
Prerequisitos: JavaScript con clases y métodos privados; DOM básico (querySelector, textContent, createElement, addEventListener)

Pausas:
1. Explica por qué total y unidades no pueden desincronizarse de las líneas, y qué tendrías que cambiar para que sí pudieran.
2. Predice qué total dará el carrito si, tras quitar el teclado, añades otro teclado, y comprueba la predicción con node.

El carrito del capítulo anterior hace lo que promete, pero su estado está repartido entre un array, unas variables y el texto de la pantalla. Para saber cuánto vale hay que mirar la página. Hoy sacamos ese estado a una clase que notifica sus cambios, y así podemos comprobar el total sin abrir el navegador.

Vamos a construir el modelo del carrito: una clase que guarda los ítems y calcula el total, y que notifica a quien se haya suscrito cada vez que algo cambia. Al terminar podrás sacar el estado de tu componente a un modelo que notifica sus cambios y probar esa lógica sin tocar el DOM.

El resultado primero

Antes de abrir la clase conviene ver qué hace. Este es el modelo del carrito del capítulo anterior, ejecutado sin ninguna pantalla: añadimos los tres productos y quitamos el primero, con un suscriptor que imprime el estado cada vez que el modelo notifica.

// extracto: va a continuación de la clase `Carrito` de la sección «El modelo»
const carrito = new Carrito();
carrito.suscribir((c) => console.log(`${c.unidades} u / $${c.total}`));
carrito.agregar({ id: 1, nombre: "Teclado", precio: 45 });
carrito.agregar({ id: 2, nombre: "Ratón", precio: 25 });
carrito.agregar({ id: 3, nombre: "Monitor", precio: 180 });
carrito.quitar(1);

Cada línea de la salida corresponde a una notificación:

1 u / $45
2 u / $70
3 u / $250
2 u / $205

Tres añadidos dejan $250 y quitar el teclado deja $205, las mismas cifras del capítulo anterior. La diferencia es dónde viven: ahora el estado está en un objeto que no sabe nada de la página. La clase que hace posible este código es la que abrimos a continuación.

Qué sale del DOM

En el carrito a lo bruto, el estado vivía en tres sitios. El array carrito guardaba los productos, las variables total y unidades guardaban los números, y cada <li> guardaba su nombre y su subtotal dentro de su texto. Cuando un cambio tocaba una de las copias, había que acordarse de tocar las demás.

Lo que sale del DOM son los ítems, las cantidades y el total. La página sigue mostrándolos, pero ya no los guarda: solo los pinta a partir de los datos. Así, el DOM deja de ser la fuente de verdad y pasa a ser un reflejo de ella.

El modelo

La clase completa cabe en un bloque. Es el carrito del capítulo anterior con sus datos en privado y un método para suscribirse; cada línea entra con cantidad 1 y el modelo no ofrece cambiarla:

class Carrito {
  #lineas = [];
  #siguienteId = 1;
  #suscriptores = [];

  suscribir(fn) {
    this.#suscriptores.push(fn);
  }

  #notificar() {
    for (const fn of this.#suscriptores) fn(this);
  }

  get lineas() {
    return this.#lineas.map((l) => ({ ...l }));
  }

  get unidades() {
    return this.#lineas.reduce((suma, l) => suma + l.cantidad, 0);
  }

  get total() {
    return this.#lineas.reduce((suma, l) => suma + l.precio * l.cantidad, 0);
  }

  agregar(producto) {
    this.#lineas.push({ linea: this.#siguienteId++, ...producto, cantidad: 1 });
    this.#notificar();
  }

  quitar(linea) {
    this.#lineas = this.#lineas.filter((l) => l.linea !== linea);
    this.#notificar();
  }
}

En esa clase tomamos tres decisiones que conviene mirar de cerca.

Los datos son privados. #lineas solo se modifica desde dentro de la clase, así que nadie puede cambiar el array por accidente desde fuera. El getter lineas devuelve copias: si alguien modifica una copia, el modelo no se entera, y eso es lo que queremos.

El total y las unidades se calculan. total y unidades no son variables: son cuentas hechas sobre las líneas en el momento en que se piden. Ya no tenemos dos sitios que sincronizar, y el primer problema del capítulo anterior, el estado sin dueño, queda resuelto por diseño.

Cada cambio termina en una notificación. Los dos métodos que modifican el estado, agregar y quitar, llaman a #notificar() al final, después de mutar. El orden importa: el suscriptor debe leer el estado ya actualizado.

pausa

Explica por qué total y unidades no pueden desincronizarse de las líneas, y qué tendrías que cambiar para que sí pudieran.

Un patrón con nombre

El modelo no sabe quién escucha ni para qué. Solo guarda una lista de suscriptores y los notifica cuando sus datos cambian. Es el patrón de observador, y conviene conocerlo con su nombre porque aparece en muchas herramientas de estado. A mano, cada manejador repintaba el total y el contador por su cuenta; con el patrón, el modelo notifica una sola vez y cada suscriptor decide qué hace con la notificación.

Dave Stearns describe este mismo diseño en su tutorial sobre MVC: un modelo que mantiene una lista de oyentes y los notifica cuando sus datos cambian, y una vista que se vuelve a pintar sola al recibir la notificación. Nuestro modelo pasa el propio carrito al suscriptor, para que pueda leer el estado sin tener una referencia aparte.

Probarlo sin DOM

Guarda la clase de arriba y este bloque en un archivo .mjs y ejecútalo con node.

// extracto: requiere la clase `Carrito` en el mismo archivo, antes de este bloque
import assert from "node:assert/strict";

const carrito = new Carrito();
carrito.agregar({ id: 1, nombre: "Teclado", precio: 45 });
carrito.agregar({ id: 2, nombre: "Ratón", precio: 25 });
carrito.agregar({ id: 3, nombre: "Monitor", precio: 180 });
assert.equal(carrito.unidades, 3);
assert.equal(carrito.total, 250);

carrito.quitar(1);
assert.equal(carrito.unidades, 2);
assert.equal(carrito.total, 205);
console.log("el carrito cuadra: 2 líneas, $205");

No hay ninguna línea de DOM. Si el total estuviera mal, la prueba lo diría en la terminal, sin abrir una página ni buscar un <span>.

pausa

Predice qué total dará el carrito si, tras quitar el teclado, añades otro teclado, y comprueba la predicción con node.

Conectar el modelo con la pantalla

El modelo no pinta nada. Para que la página se entere de los cambios, alguien tiene que suscribirse y pintar. Este es el marcado del carrito, el mismo del capítulo anterior:

<ul id="catalogo"></ul>
<h3>Tu carrito (<span id="contador">0</span>)</h3>
<ul id="carrito"></ul>
<p>Total: <span id="total">$0</span></p>

El catálogo ya no guarda nada: cada «Añadir» le pide al modelo que agregue el producto.

// extracto: sigue a `const carrito = new Carrito();` del bloque anterior
const productos = [
  { id: 1, nombre: "Teclado", precio: 45 },
  { id: 2, nombre: "Ratón", precio: 25 },
  { id: 3, nombre: "Monitor", precio: 180 },
];

for (const producto of productos) {
  const li = document.createElement("li");
  li.textContent = `${producto.nombre} — $${producto.precio} `;
  const añadir = document.createElement("button");
  añadir.textContent = "Añadir";
  añadir.addEventListener("click", () => carrito.agregar(producto));
  li.append(añadir);
  document.querySelector("#catalogo").append(li);
}

El suscriptor pinta el carrito a partir del modelo:

// extracto: sigue al bloque anterior
const totalEl = document.querySelector("#total");
const contadorEl = document.querySelector("#contador");
const listaEl = document.querySelector("#carrito");

carrito.suscribir((c) => {
  totalEl.textContent = `$${c.total}`;
  contadorEl.textContent = c.unidades;
  listaEl.replaceChildren(
    ...c.lineas.map((l) => {
      const li = document.createElement("li");
      li.textContent = `${l.nombre} x${l.cantidad} — $${l.precio * l.cantidad} `;
      const quitar = document.createElement("button");
      quitar.textContent = "Quitar";
      quitar.addEventListener("click", () => carrito.quitar(l.linea));
      li.append(quitar);
      return li;
    }),
  );
});

Cada notificación repinta el total, el contador y la lista entera. Es poco código, y los datos solo viven en el modelo: la pantalla es una copia que se regenera en cada cambio.

Los límites de este paso

Este paso deja cuatro cosas sin resolver, y conviene nombrarlas.

  • La cantidad no se puede editar. El capítulo anterior tenía un campo por línea; aquí cada producto entra con cantidad 1 y el modelo no ofrece cambiarla. Editar la cantidad queda fuera de este capítulo.
  • La pantalla no se actualiza sola. Si alguien olvida suscribirse, la página se queda con su marcado inicial: $0 y la lista vacía. El modelo no puede evitarlo.
  • El código que pinta sigue junto al que cambia los datos. Quien llama a agregar y a quitar es el mismo componente que decide cómo se ve la lista, así que la separación todavía está a medias.
  • El suscriptor repinta todo en cada notificación. Con tres líneas no se nota. Con una lista larga, habrá que decidir qué repintar y cuándo.

El punto

El DOM ya no es la verdad, pero sigue sin actualizarse solo. ¿Quién lo mantiene al día sin que tengamos que escribir cada suscriptor a mano? Esa es la pregunta del siguiente capítulo: la vista.