← El DOM: la web que responde

Sesión 4

Estado y persistencia

Tres horas. Media hora para entender por qué la interfaz debe tener una sola fuente de verdad, y dos horas y media reescribiendo tu página alrededor de ella y haciendo que recuerde las preferencias entre visitas.

Hoy · Hoja de ruta

  1. 1. Aprende: Qué es el estado de una interfaz, el ciclo evento → estado → render, cómo se combinan varios filtros y cómo se guarda algo en el navegador sin fiarse de lo guardado.
  2. 2. Haz: Reescribe tu página con un estado único, monta el panel de filtros completo y recuerda las preferencias.
  3. 3. Comprueba: Ninguna decisión se toma leyendo la página, y un dato guardado corrupto no rompe nada.

Antes de empezar · 5 minutos, sin apuntes

  1. Ahora mismo, ¿dónde está guardado qué filtro está activo?
  2. Si hubiera dos sitios donde consultarlo, ¿qué podría pasar?
  3. ¿Qué debería recordar tu web entre visitas? ¿Y qué no?

Se explica

25 minutos · conceptos y demostración

La sesión tiene una sola idea, y es la que separa una página que funciona de una que se puede mantener: la información vive en un sitio, y la página es su reflejo. Todo lo demás de hoy se deduce de ahí.

El problema que resuelve

Sin estado, la información se reparte: el término de búsqueda está en el campo, la categoría en la clase de un botón, el orden en una variable y los resultados en el DOM. Cuatro sitios que hay que mantener de acuerdo a mano, y que tarde o temprano dejan de estarlo.

Estado

Un objeto que contiene todo lo que hace falta para saber cómo debe verse la interfaz en este momento. Si lo tienes, puedes pintar la página entera desde cero.

// js/estado.js
export const estado = {
  productos: [],
  busqueda: "",
  categoria: "todas",
  orden: "precio-asc",
  soloDisponibles: false,
  cargando: false,
  error: null
};

El ciclo

function actualizar() {
  const visibles = aplicarFiltros(estado);
  pintarCatalogo(visibles, elementos.catalogo);
  pintarResumen(visibles.length, estado.productos.length, elementos.resumen);
  sincronizarControles(estado, elementos);
}

elementos.buscador.addEventListener("input", (evento) => {
  estado.busqueda = evento.target.value;
  actualizar();
});

Cada manejador hace exactamente dos cosas: cambiar el estado y pedir que se actualice. Ninguno toca el DOM directamente.

Una dirección única
  1. Evento
  2. Cambia el estado
  3. actualizar()
  4. La página refleja el estado

No preguntes a la página; pregunta al estado

La tentación es comprobar si un botón tiene una clase para saber si el filtro está activo. Eso convierte al DOM en un almacén de datos, y a partir de ahí hay dos verdades que pueden discrepar.

El DOM es salida, no memoria. Se escribe en él; no se lee de él.

Los filtros se aplican en cadena, en un solo sitio: cada uno reduce lo que dejó el anterior, y el resultado son los productos que cumplen todo a la vez.

export function aplicarFiltros(estado) {
  let resultado = estado.productos;

  if (estado.busqueda.trim() !== "") resultado = buscar(resultado, estado.busqueda);
  if (estado.categoria !== "todas") resultado = porCategoria(resultado, estado.categoria);
  if (estado.soloDisponibles) resultado = disponibles(resultado);

  return ordenar(resultado, estado.orden);
}

Es una función pura: recibe el estado y devuelve la lista que toca. Se puede probar sin abrir el navegador, que es exactamente lo que la hace fácil de arreglar cuando algo falla. Añadir un filtro nuevo pasa a ser añadir tres líneas.

Hay además dos decisiones de producto que conviene tomar a conciencia:

Situación Decisión razonable
Búsqueda vacía No filtra nada
Categoría «todas» No filtra nada
Precio máximo vacío Sin límite, no cero
Ningún resultado Mensaje con salida: «quita algún filtro»

No trabajar de más

Filtrar cuatrocientos productos con cada tecla es trabajo repetido. Se limita con una función que espera a que la persona deje de escribir:

function retrasar(funcion, milisegundos = 250) {
  let temporizador;
  return (...argumentos) => {
    clearTimeout(temporizador);
    temporizador = setTimeout(() => funcion(...argumentos), milisegundos);
  };
}

Debounce

Agrupar una ráfaga de eventos en una sola ejecución, la última. Con un buscador local su efecto es reducido; en la sesión 5, cuando cada pulsación implique una petición al servidor, resultará obligatorio.

Fíjate en que retrasar es una función que devuelve otra función: exactamente lo que practicaste en la sesión 3 de la UD3.

Anunciar el resultado

<p data-js="resumen" role="status" aria-live="polite"></p>
export function pintarResumen(visibles, total, destino) {
  destino.textContent = visibles === total
    ? `${total} productos`
    : `${visibles} de ${total} productos`;
}

Un cambio que no se anuncia, para algunas personas no ocurre

Cuando el catálogo se filtra, quien ve la pantalla percibe el cambio al instante. Quien usa un lector de pantalla no se entera de nada, porque el foco sigue en el campo de búsqueda.

Una región con aria-live="polite" hace que el lector anuncie el nuevo texto sin interrumpir. Es una línea de HTML y cambia por completo la experiencia.

Recordar entre visitas

localStorage.setItem("preferencias", JSON.stringify(preferencias));
const texto = localStorage.getItem("preferencias");   // texto, o null
localStorage.removeItem("preferencias");

Solo guarda texto, así que todo pasa por JSON.stringify y JSON.parse: las dos funciones de la sesión 4 de la UD3.

Almacén Dura Para qué
localStorage Hasta que se borre Preferencias, borradores
sessionStorage Hasta cerrar la pestaña Datos de un proceso en curso
Cookies Según se configuren Sesión de servidor (UD6)

Lo guardado es entrada externa

Cualquiera puede abrir DevTools y editarlo. Puede estar corrupto, puede ser de una versión anterior de tu web, puede no estar. Si tu código hace JSON.parse a pelo, un valor manipulado deja la página en blanco.

Léelo dentro de un try/catch, valida su forma, y si algo no cuadra, usa los valores por defecto y sigue. Es el mismo principio de la UD3: validar en el borde.

Nunca datos personales ni credenciales

Lo que guardes ahí es legible por cualquiera que se siente delante del equipo y por cualquier código que se ejecute en tu página. Preferencias de interfaz, sí. Contraseñas, tokens, datos de tarjetas o información personal, no.

Y si guardas algo que identifique a una persona, entras en el terreno del consentimiento y la protección de datos, que es harina de otro costal.

Así queda una lectura que no se puede romper desde fuera:

const PREFERENCIAS_POR_DEFECTO = { categoria: "todas", orden: "precio-asc" };

export function leerPreferencias() {
  try {
    const guardado = localStorage.getItem("preferencias");
    if (!guardado) return { ...PREFERENCIAS_POR_DEFECTO };

    const datos = JSON.parse(guardado);
    return {
      categoria: typeof datos.categoria === "string" ? datos.categoria : "todas",
      orden: typeof datos.orden === "string" ? datos.orden : "precio-asc"
    };
  } catch {
    return { ...PREFERENCIAS_POR_DEFECTO };
  }
}

Hay además límites de tamaño —unos pocos megabytes— y el acceso puede fallar directamente en navegación privada o con el almacenamiento bloqueado. Otra razón para el try/catch.

Se trabaja

150 minutos · práctica sobre tu propio proyecto

El primer paso reescribe lo que ya tenías; parece un retroceso y es el cambio más rentable de la unidad. Los cuatro siguientes construyen encima.

Paso 1 · Reescribir con estado · 40 min

  1. Crea js/estado.js con el objeto y sus valores iniciales.
  2. Escribe aplicarFiltros(estado) combinando tus funciones de la UD3.
  3. Escribe actualizar() como único punto que pinta.
  4. Convierte todos tus manejadores al patrón «cambia estado, actualiza». Ninguno debe pasar de tres líneas.
  5. Elimina cualquier lectura del DOM que sirviera para decidir algo. Busca classList.contains y .value fuera de los manejadores: ahí suelen estar.
  6. Escribe sincronizarControles(estado, elementos) para que los campos reflejen el estado, y no al revés.

Antes de continuar: ninguna función que no sea actualizar o una de render toca el DOM.

Paso 2 · El panel completo · 40 min

  1. Añade al menos tres controles: búsqueda, categoría y orden.
  2. Genera las opciones de categoría desde los datos, no a mano. Si mañana aparece una categoría nueva, el desplegable debe incluirla sin tocar el HTML.
  3. Añade una casilla de «solo disponibles».
  4. Aplica el retraso a la búsqueda y comprueba la diferencia contando ejecuciones en consola.
  5. Anuncia el número de resultados en una región con aria-live.
  6. Añade un botón de limpiar que restaure el estado inicial y devuelva el foco al buscador.

Paso 3 · Preferencias que duran · 35 min

  1. Guarda categoría, orden y vista compacta al cambiarlas.
  2. Recupéralas al arrancar y aplícalas al estado antes del primer render.
  3. Valida lo leído y usa valores por defecto si no cuadra.
  4. Añade un botón de «restablecer preferencias».
  5. Escribe en tus notas qué has decidido no guardar, y por qué. El término de búsqueda es el caso interesante: piensa qué se encuentra alguien al volver mañana.

Paso 4 · Rompe lo que has guardado · 15 min

Abre DevTools, ve al almacenamiento local y estropea el valor a mano. Prueba estos cinco casos y anota qué hace tu web con cada uno:

Lo que hay guardado Qué debería pasar Qué pasa Corrección
Nada, primera visita
Un JSON válido pero con campos de más
Un JSON válido con orden puesto a un número
Texto que no es JSON
Un JSON con una categoría que ya no existe

Los dos últimos son los que suelen dejar la página en blanco. El tercero es más sutil: el JSON es válido, el campo existe, y su valor no sirve.

Paso 5 · La página desde la consola · 20 min

Si el estado es de verdad la única fuente de verdad, debería poder gobernarse sin tocar la interfaz.

  1. Desde la consola, cambia estado.categoria a mano y llama a actualizar(). La página debe responder.
  2. Repítelo con los cinco campos del estado.
  3. Comprueba que los controles también se actualizan: el desplegable debe mostrar la categoría que has puesto. Si no lo hace, te falta sincronizarControles.
  4. Pon el estado en una combinación concreta, anótala, recarga la página y vuelve a ponerla. ¿Se ve exactamente lo mismo? Si no, hay información viviendo fuera del estado: encuéntrala.
  5. Escribe en un comentario qué campo del estado te ha costado más reproducir, y por qué.

Ampliación si has completado el trabajo

Primero termina y comprueba los cinco pasos. Los dos retos empujan el estado fuera de la memoria: a la URL y al futuro.

Reto 1 · El estado en la dirección

Ahora mismo, si alguien filtra por «teclados», ordena por precio y quiere enseñárselo a otra persona, no puede: la dirección de la página es la misma que al entrar.

  1. Escribe los filtros activos en la URL con URLSearchParams y history.replaceState, de modo que la barra de direcciones refleje el estado.
  2. Al cargar la página, lee la URL y úsala para inicializar el estado.
  3. Decide qué gana cuando hay conflicto: lo que dice la URL o lo que había guardado en las preferencias. No hay una respuesta única; justifica la tuya.
  4. Comprueba que copiar la dirección y abrirla en otra pestaña reproduce exactamente la misma vista.
  5. Ahora el botón de retroceso: usa pushState en lugar de replaceState para un filtro concreto y escucha popstate. ¿Qué gana y qué pierde la interfaz? Anótalo: hay un motivo real para no guardar en el historial cada pulsación del buscador.
  6. Con esto acabas de reproducir lo que hace cualquier tienda en línea. Escribe en tres líneas qué ventaja tiene para quien usa la web, más allá de compartir el enlace.

Reto 2 · Lo guardado de la versión anterior

Tu web ya funciona. Dentro de dos meses la cambias, y quienes vuelvan traerán en su navegador las preferencias del formato viejo.

  1. Simula el escenario: guarda a mano unas preferencias con el formato actual, cambia después la estructura del objeto —renombra orden como ordenacion y añade un campo nuevo—, y recarga.
  2. Anota qué ocurre. Con suerte tu lectura defensiva lo salva; lo que seguro se pierde es la preferencia de la persona.
  3. Añade un campo version a lo guardado y escribe una función migrar(datos) que convierta el formato antiguo al nuevo en lugar de descartarlo.
  4. Comprueba las tres situaciones: sin nada guardado, con el formato viejo y con el nuevo.
  5. Decide qué hacer con una versión más nueva que la que entiende tu código, que ocurre cuando alguien abre una pestaña antigua. Es el caso que casi nadie contempla.
  6. Escribe en tres líneas por qué esto no es una manía: relaciona la respuesta con lo que ocurrirá en la UD6 cuando los datos vivan en un servidor y el formato cambie.
Objetivo mínimoEstado único con manejadores de tres líneas, tres filtros combinables con su resumen anunciado, y preferencias que sobreviven a recargar.
Si lo tienesLa tabla de datos corruptos contestada y la página gobernada entera desde la consola.
RetoEl estado reflejado en la URL y compartible, o la migración de preferencias entre versiones.

Cierre

5 minutos · comprobación y recuerdo

Lista de verificación de la sesión

  • Existe un único objeto de estado y ninguna decisión se toma leyendo el DOM.
  • Los manejadores cambian estado y llaman a actualizar.
  • Los filtros se combinan en un solo sitio y ninguno pisa a otro.
  • Las opciones de categoría se generan desde los datos.
  • El número de resultados se anuncia a los lectores de pantalla.
  • Un dato guardado inválido no rompe nada.

Antes de cerrar · 3 minutos, sin mirar

  1. ¿Qué es el estado de una interfaz?
  2. ¿Por qué el DOM no debe ser el almacén de datos?
  3. ¿Qué dos cosas hace un manejador de eventos?
  4. ¿Qué hace un debounce?
  5. ¿Para qué sirve aria-live?
  6. ¿Por qué hay que desconfiar de lo guardado en el navegador?
Ver respuestas

1 · Todo lo necesario para saber cómo debe verse la página ahora mismo.

2 · Porque tendrías dos fuentes de verdad que pueden contradecirse.

3 · Cambiar el estado y pedir que se vuelva a pintar.

4 · Esperar a que pare la ráfaga de eventos y ejecutar una sola vez.

5 · Para que un lector de pantalla anuncie los cambios de esa zona sin que haya que moverse hasta ella.

6 · Porque es editable por cualquiera y puede venir de una versión anterior de la web.

Microprueba semanal 4 · 5–10 minutos

Individual, sin IA y sin apuntes.

  1. Enumera los campos que tendría el estado de tu página.
  2. Un manejador de eventos, ¿qué dos cosas hace exactamente?
  3. ¿Qué debe hacer un precio máximo vacío, y por qué no puede valer cero?