Ya sabes fabricar el <li> de una tarea. Lo que todavía no tienes es una forma de trabajar: ahora mismo pintas una vez al arrancar y después vas parcheando cada elemento a mano dentro del manejador de clic. Eso funciona con tres acciones y se vuelve inmanejable con quince, porque cada acción tiene que acordarse de actualizar el <li>, el resumen, el contador de horas, el panel de carga y lo que venga después. La alternativa es un cambio de mentalidad: en lugar de describir qué hay que modificar, se escribe una función que describe cómo debe verse la pantalla dado un estado, y se vuelve a llamar cada vez que el estado cambia. En esta lección construirás ese ciclo, aprenderás a agrupar el tablero por columnas, a escapar datos si usas plantillas de texto, y a resolver el problema que aparece en cuanto redibujas todo: que el foco, el desplazamiento y lo que el usuario estaba escribiendo se pierden por el camino.
Contenido
- Del nodo suelto a la lista completa:
render(estado) - El ciclo estado → render → evento → nuevo estado → render
- Plantillas con template literals y
escaparHtml - Tres formas de producir HTML, comparadas
<template>para las tarjetas- Agrupar el tablero por estado con
Object.groupBy - Estados vacíos, contadores y resumen
- El problema de redibujar todo
- Actualización parcial: claves y reutilización de nodos
reconciliar(lista, datos)- Ordenar y filtrar en la vista sin tocar el modelo
- Nómada Tareas:
vista/tablero-vista.js - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Del nodo suelto a la lista completa:
render(estado)
render(estado)Compara las dos formas de pensar:
// ✗ Imperativa: describir los cambios, uno a uno
boton.addEventListener('click', () => {
tablero.cambiarEstado(id, 'hecha');
li.classList.add('tarea--hecha');
li.querySelector('.tarea__meta').textContent = '…';
boton.disabled = true;
resumen.textContent = '…';
contadorHoras.textContent = '…';
// … y todo lo que alguien añada mañana
});
// ✓ Declarativa: describir el resultado, y volver a dibujarlo
boton.addEventListener('click', () => {
tablero.cambiarEstado(id, 'hecha');
render(); // una sola línea, siempre la misma
});La función render toma el estado y produce la pantalla. Nada más. No sabe qué ha cambiado ni le importa: dibuja lo que hay ahora.
/** Estado de la vista: el modelo + las preferencias de visualización. */
const estado = {
tablero, // el Tablero del modelo
filtros: { responsable: null, estado: null },
orden: 'prioridad',
hoy: HOY
};
function render() {
const visibles = aplicarFiltros(estado);
lista.replaceChildren(...visibles.map((t) => crearTarjeta(t, estado.hoy)));
pintarResumen(resumen, estado.tablero, estado.hoy);
}La ventaja no es escribir menos código: es que la pantalla no puede desincronizarse del estado. En la versión imperativa siempre acaba habiendo un caso en que alguien olvida actualizar una de las cinco cosas y la interfaz muestra una mentira. En la declarativa, eso es imposible por construcción.
- El ciclo estado → render → evento → nuevo estado → render
Esta es la arquitectura de casi cualquier aplicación de interfaz moderna, y ahora la vas a escribir a mano:
flowchart LR
E["ESTADO<br/>Tablero + filtros + orden"] --> R["render(estado)<br/>dibuja la pantalla"]
R --> P["PANTALLA<br/>lista, resumen, contadores"]
P --> V["El usuario actúa<br/>clic, tecla, envío"]
V --> M["Manejador delegado<br/>llama al MODELO"]
M --> N["NUEVO ESTADO<br/>tablero.cambiarEstado(...)"]
N --> R
Las reglas del ciclo, que conviene respetar con disciplina:
- Un único sentido. El estado alimenta a la pantalla; la pantalla nunca es la fuente de verdad. Si necesitas saber qué tareas están hechas, preguntas al
Tablero, no cuentas elementos con clasetarea--hecha. - Los manejadores no tocan el DOM. Modifican el estado (a través del modelo) y llaman a
render(). Uno solo. renderes (casi) pura. Dado el mismo estado, produce la misma pantalla. Su único efecto secundario es escribir en el DOM.- El modelo sigue sin saber que existe una pantalla.
TareayTablerono cambian ni una línea en esta lección.
- Plantillas con template literals y
escaparHtml
escaparHtmlHay una forma de construir HTML que resulta muy legible: los template literals de 01-05, con la estructura escrita tal cual y los datos interpolados.
function plantillaTarea(tarea) {
return `
<li class="tarea tarea--${tarea.prioridad}" data-id="${tarea.id}">
<span class="tarea__titulo">${tarea.titulo}</span>
<span class="tarea__meta">${tarea.responsable} · ${tarea.horasEstimadas} h</span>
<button type="button" data-accion="avanzar">Empezar</button>
</li>`;
}
lista.innerHTML = tablero.tareas.map(plantillaTarea).join('');Se lee de maravilla. Y tal como está escrito, es una vulnerabilidad. En 06-07 Marta podrá escribir el título de una tarea; si escribe <img src=x onerror="…">, ese código se ejecutará en tu página.
La única forma de usar este enfoque con seguridad es escapar todos los datos interpolados, sin excepción:
// js/vista/dom.js
const ESCAPES = Object.freeze({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
});
/** Convierte los caracteres con significado en HTML en sus entidades. */
export function escaparHtml(valor) {
return String(valor).replace(/[&<>"']/g, (caracter) => ESCAPES[caracter]);
}console.log(escaparHtml('<img src=x onerror="robar()">'));
// '<img src=x onerror="robar()">' ← se verá como texto, no se ejecutará
console.log(escaparHtml('Tintas & pantallas'));
// 'Tintas & pantallas'Detalles que hacen que esta función sea correcta y no una falsa sensación de seguridad:
&se escapa el primero en el orden de la expresión regular, porque si se escapara después convertiría los<ya generados en&lt;. Usarreplacecon una función de reemplazo evita el problema de raíz, porque cada carácter se procesa una sola vez.- Se escapan también las comillas. Sin ellas, un dato interpolado dentro de un atributo (
data-id="${valor}") podría cerrar el atributo e inyectar otros. Es un vector real. String(valor)protege denull,undefinedy números.
Y una advertencia que no hay que olvidar: escapar sirve para contenido de texto y atributos entrecomillados. No es suficiente si interpolas dentro de un <script>, dentro de un style, o en un atributo href/src (donde un javascript: sigue siendo peligroso). Para esos casos, no interpoles: usa nodos.
Con la función, la plantilla queda así:
import { escaparHtml as esc } from './dom.js';
function plantillaTarea(tarea) {
return `
<li class="tarea tarea--${esc(tarea.prioridad)}" data-id="${esc(tarea.id)}">
<span class="tarea__titulo">${esc(tarea.titulo)}</span>
<span class="tarea__meta">${esc(tarea.responsable ?? 'sin asignar')} · ${esc(tarea.horasEstimadas)} h</span>
<button type="button" data-accion="avanzar">Empezar</button>
</li>`;
}Funciona, es seguro… y depende de que nadie olvide nunca un esc(). Un solo despiste reabre el agujero. Esa fragilidad es el argumento decisivo contra este enfoque, y la razón de que las alternativas del apartado siguiente sean preferibles.
- Tres formas de producir HTML, comparadas
| Criterio | Template literal + innerHTML |
createElement / crearElemento |
<template> + cloneNode |
|---|---|---|---|
| Legibilidad de la estructura | Muy alta | Media | Muy alta (está en el HTML) |
| Seguridad | Depende de escapar siempre | Segura por construcción | Segura (se rellena con textContent) |
| Referencias a los nodos creados | Hay que volver a buscarlos | Ya las tienes | Se buscan dentro del clon |
| Conserva nodos, foco y estado | No: destruye y recrea | Sí, si actualizas en lugar de recrear | Sí, igual |
| Coste | Analizar HTML en cada render | Llamadas a métodos | Clonar (muy barato) |
| Estructuras condicionales | Fácil con ternarios | Fácil con filter(Boolean) |
Incómodo: hay que ocultar partes |
| Riesgo de error humano | Alto (olvidar un esc) |
Bajo | Bajo |
Conclusión operativa para Nómada Tareas: <template> para la estructura fija de la tarjeta, crearElemento para lo dinámico, y los template literals solo para marcado constante sin datos. Es el equilibrio entre legibilidad y seguridad, y elimina la clase entera de errores de escapado.
<template> para las tarjetas
<template> para las tarjetasRetomamos la plantilla de 06-05 y le damos su forma definitiva, con las columnas incluidas:
<template id="plantilla-tarea">
<li class="tarea" tabindex="-1">
<span class="tarea__titulo"></span>
<span class="tarea__meta"></span>
<ul class="tarea__etiquetas"></ul>
<button type="button" class="tarea__accion" data-accion="avanzar"></button>
<button type="button" class="tarea__accion" data-accion="reabrir">Reabrir</button>
</li>
</template>
<template id="plantilla-columna">
<section class="columna" aria-labelledby="">
<h3 class="columna__titulo"></h3>
<p class="columna__contador"></p>
<ul class="lista-tareas"></ul>
<p class="columna__vacia" hidden>No hay tareas en esta columna.</p>
</section>
</template>Y la función que la rellena. Fíjate en que ahora actualiza un <li> existente si se lo pasas, y solo clona si no hay ninguno: esa doble capacidad es la que hará posible la reconciliación del apartado 10.
// js/vista/tarjeta.js
import { $ } from './dom.js';
import { HOY } from '../util/fechas.js';
import { marcaDeEstado } from '../util/formato.js';
const CLASES_PRIORIDAD = ['tarea--alta', 'tarea--media', 'tarea--baja'];
const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
const ETIQUETA = Object.freeze({
pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada'
});
/**
* Crea o ACTUALIZA el <li> de una tarea.
* @param {Tarea} tarea
* @param {HTMLElement|null} existente si se pasa, se reutiliza en lugar de clonar
*/
export function pintarTarjeta(tarea, existente = null, hoy = HOY) {
const li = existente ?? $('#plantilla-tarea').content.firstElementChild.cloneNode(true);
const vencida = tarea.estaVencida(hoy);
li.dataset.id = tarea.id;
li.dataset.estado = tarea.estado;
li.dataset.responsable = tarea.responsable ?? '';
li.dataset.prioridad = tarea.prioridad;
li.classList.remove(...CLASES_PRIORIDAD);
li.classList.add(`tarea--${tarea.prioridad}`);
li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');
li.classList.toggle('tarea--vencida', vencida);
$('.tarea__titulo', li).textContent = tarea.titulo;
$('.tarea__meta', li).textContent =
`${marcaDeEstado(tarea.estado)} ${tarea.responsable ?? 'sin asignar'} · ` +
`${tarea.horasEstimadas} h` + (vencida ? ` · vencida hace ${Math.abs(tarea.diasRestantes)} d` : '');
const etiquetas = $('.tarea__etiquetas', li);
etiquetas.replaceChildren(...tarea.etiquetas.map((texto) => {
const item = document.createElement('li');
item.className = 'etiqueta';
item.textContent = texto;
return item;
}));
const avanzar = $('[data-accion="avanzar"]', li);
avanzar.textContent = ETIQUETA[tarea.estado];
avanzar.disabled = SIGUIENTE[tarea.estado] === null;
avanzar.setAttribute('aria-label', `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`);
const reabrir = $('[data-accion="reabrir"]', li);
reabrir.disabled = tarea.estado !== 'en-curso';
reabrir.setAttribute('aria-label', `Devolver a pendiente: ${tarea.titulo}`);
return li;
}Una sola función sirve para crear y para actualizar. Eso es exactamente lo que necesita un render que se ejecuta muchas veces.
- Agrupar el tablero por estado con
Object.groupBy
Object.groupByEl tablero de Taller Nómada tiene tres columnas: pendiente, en curso y hecha. Agrupar es una línea, con lo que aprendiste en 04-05:
const COLUMNAS = [
{ estado: 'pendiente', titulo: 'Pendientes' },
{ estado: 'en-curso', titulo: 'En curso' },
{ estado: 'hecha', titulo: 'Hechas' }
];
const porEstado = Object.groupBy(tablero.tareas, (t) => t.estado);
console.log(Object.keys(porEstado)); // ['en-curso', 'pendiente', 'hecha']
console.log(porEstado.pendiente.length); // 3 ← ids 2, 3 y 6
console.log(porEstado['en-curso'].map((t) => t.id)); // [1, 5]
console.log(porEstado.hecha.map((t) => t.id)); // [4]Dos avisos sobre Object.groupBy:
- Solo crea las claves que aparecen. Si ninguna tarea está hecha,
porEstado.hechaesundefined, no un array vacío. Usa?? []siempre. - El objeto que devuelve tiene el prototipo
null, así que no tienehasOwnPropertynitoString. Para consultar claves,Object.hasOwn(porEstado, 'hecha')o directamente el??.
Con eso, dibujar las tres columnas es directo:
function renderColumnas(contenedor, tareas, hoy) {
const porEstado = Object.groupBy(tareas, (t) => t.estado);
contenedor.replaceChildren(...COLUMNAS.map(({ estado, titulo }) => {
const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
const delEstado = porEstado[estado] ?? [];
columna.dataset.estado = estado;
$('.columna__titulo', columna).textContent = titulo;
$('.columna__titulo', columna).id = `columna-${estado}`;
columna.setAttribute('aria-labelledby', `columna-${estado}`);
const horas = delEstado.reduce((suma, t) => suma + t.horasEstimadas, 0);
$('.columna__contador', columna).textContent =
`${delEstado.length} tarea${delEstado.length === 1 ? '' : 's'} · ${horas} h`;
$('.lista-tareas', columna).replaceChildren(...delEstado.map((t) => pintarTarjeta(t, null, hoy)));
$('.columna__vacia', columna).hidden = delEstado.length > 0;
return columna;
}));
}Con el backlog canónico, el resultado es:
Pendientes · 3 tareas · 25 h (Cartelería 6, Web de reservas 14, Carpintería 5) En curso · 2 tareas · 20 h (Sala polivalente 12, Guía de encuadernación 8) Hechas · 1 tarea · 3 h (Inventario de tintas)
Y 25 + 20 = 45 h abiertas, con 48 h totales. Los números canónicos, ahora repartidos en columnas.
El CSS mínimo para que se vean como tal:
.tablero { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.columna { border: 1px solid var(--borde); border-radius: 0.5rem; padding: 0.75rem; }
.columna__titulo { margin: 0 0 0.25rem; font-size: 1rem; }
.columna__contador { margin: 0 0 0.75rem; color: var(--gris); font-size: 0.85rem; }
.columna__vacia { color: var(--gris); font-style: italic; }
.tarea__etiquetas { list-style: none; display: flex; gap: 0.3rem; padding: 0; margin: 0.3rem 0 0; }
.etiqueta { font-size: 0.75rem; background: #f3f4f6; border-radius: 999px; padding: 0.1rem 0.5rem; }
- Estados vacíos, contadores y resumen
Un detalle que separa una interfaz acabada de un prototipo: qué se ve cuando no hay nada. Una columna vacía sin mensaje parece un error de carga.
Ya lo has resuelto arriba con $('.columna__vacia', columna).hidden = delEstado.length > 0, pero conviene distinguir tres situaciones distintas, porque el mensaje adecuado no es el mismo:
| Situación | Mensaje adecuado |
|---|---|
| No hay tareas en esta columna | «No hay tareas en esta columna.» |
| Hay tareas, pero el filtro las oculta todas | «Ninguna tarea coincide con el filtro. Quitar filtros» |
| El tablero está completamente vacío | «Aún no hay tareas. Crea la primera con el formulario.» |
function mensajeVacio(estado, hayTareasEnTotal, hayFiltroActivo) {
if (!hayTareasEnTotal) return 'Aún no hay tareas. Crea la primera con el formulario.';
if (hayFiltroActivo) return 'Ninguna tarea coincide con el filtro actual.';
return 'No hay tareas en esta columna.';
}Y el resumen global, que reutiliza el método del modelo:
function renderResumen(elemento, tablero, hoy) {
const { total, abiertas, horasTotales, horasAbiertas, vencidas, esfuerzo } = tablero.resumen(hoy);
elemento.textContent =
`${total} tareas · ${abiertas} abiertas · ${horasAbiertas} de ${horasTotales} h · ` +
`${vencidas} vencida${vencidas === 1 ? '' : 's'} · esfuerzo ${esfuerzo}`;
}
// 6 tareas · 5 abiertas · 45 de 48 h · 1 vencida · esfuerzo 124Ese elemento lleva role="status" en el HTML (06-01), de modo que un lector de pantalla anuncia el nuevo resumen cada vez que cambia, sin robar el foco.
- El problema de redibujar todo
Y ahora la mala noticia. replaceChildren(...) destruye todos los nodos y crea otros. Eso tiene cuatro consecuencias visibles:
- Se pierde el foco. Si Marta había llegado con el tabulador al botón «Marcar hecha» de la tercera tarea y pulsa Enter, el render la deja con el foco en
document.body. Su siguiente Tab la lleva al principio de la página. - Se pierde el desplazamiento dentro de contenedores con barra propia.
- Se pierde el estado del navegador: texto seleccionado,
<details>abiertos, la posición del cursor dentro de un<input>que estuviera en la lista. - Se reinician las transiciones CSS, porque los elementos son nuevos.
Hay dos estrategias para resolverlo.
Estrategia A: salvar y restaurar. Antes de redibujar, anota qué estaba enfocado; después, vuelve a buscarlo por su clave estable y devuélvele el foco.
function conFocoConservado(render) {
const activo = document.activeElement;
const idTarea = activo?.closest?.('[data-id]')?.dataset.id ?? null;
const accion = activo?.dataset?.accion ?? null;
const scroll = contenedor.scrollTop;
render();
contenedor.scrollTop = scroll;
if (idTarea !== null) {
const destino = $(`[data-id="${idTarea}"] [data-accion="${accion}"]`) ??
$(`[data-id="${idTarea}"]`);
if (destino !== null && !destino.disabled) destino.focus();
}
}Funciona y es sencilla, pero es un parche: cuantas más cosas haya que conservar, más frágil se vuelve.
Estrategia B: no destruir lo que no ha cambiado. Es la buena, y es lo que hacen los frameworks. En lugar de vaciar y recrear, se compara la lista de nodos actuales con los datos nuevos y se reutiliza cada nodo que siga siendo válido, actualizando solo su contenido.
- Actualización parcial: claves y reutilización de nodos
Para reutilizar un nodo hay que poder identificarlo. La posición no vale: si la primera tarea desaparece, todas las demás se desplazan y el nodo de la segunda pasaría a representar a la tercera, con el foco y las animaciones en el sitio equivocado.
Lo que vale es una clave estable: un identificador que pertenezca al dato, no a su posición. En Nómada Tareas ya la tienes desde 06-02: data-id.
flowchart TD
D["Datos nuevos<br/>ids: 3, 6, 1"] --> C{"¿Existe ya un nodo<br/>con ese data-id?"}
C -- "Sí" --> A["Actualizar su contenido<br/>(conserva foco y estado)"]
C -- "No" --> N["Crear la tarjeta nueva"]
A --> O["Colocar en el orden correcto"]
N --> O
O --> S["Eliminar los nodos sobrantes<br/>(ids que ya no están)"]
reconciliar(lista, datos)
reconciliar(lista, datos)Aquí está la implementación, y es más corta de lo que parece:
// js/vista/dom.js
/**
* Sincroniza los hijos de un contenedor con una lista de datos, reutilizando
* los nodos que ya existen. Cada dato debe tener una clave estable.
*
* @param {HTMLElement} contenedor
* @param {Array} datos
* @param {(dato) => string|number} clave identificador estable del dato
* @param {(dato, nodoExistente|null) => HTMLElement} pintar crea o actualiza el nodo
*/
export function reconciliar(contenedor, datos, clave, pintar) {
// 1 · Indexamos los nodos actuales por su clave (el reduce de 04-05)
const existentes = new Map(
[...contenedor.children].map((nodo) => [nodo.dataset.clave ?? nodo.dataset.id, nodo])
);
// 2 · Recorremos los datos en su orden final
let referencia = contenedor.firstElementChild;
for (const dato of datos) {
const k = String(clave(dato));
const previo = existentes.get(k) ?? null;
const nodo = pintar(dato, previo); // reutiliza si previo !== null
existentes.delete(k); // marcado como "sigue vivo"
if (nodo !== referencia) {
contenedor.insertBefore(nodo, referencia); // mueve o inserta en el sitio correcto
} else {
referencia = referencia.nextElementSibling; // ya estaba bien colocado
}
}
// 3 · Lo que quede en el mapa ya no está en los datos: fuera
for (const sobrante of existentes.values()) sobrante.remove();
}Explicación línea a línea de lo que hace y por qué:
- Paso 1: construye un
Mapde clave → nodo con lo que hay en pantalla. Es exactamente el patrón de indexar poridde 04-05, aplicado a nodos en lugar de a objetos. - Paso 2: recorre los datos en el orden en que deben quedar. Para cada uno, si ya existía un nodo con esa clave lo reutiliza (
pintar(dato, previo)lo actualiza en lugar de clonar), y si no, crea uno nuevo. Después lo coloca en su posición coninsertBefore, que —recuerda 06-05— mueve el nodo si ya estaba en el árbol. - Paso 3: lo que queda en el
Mapson nodos cuya clave ya no aparece en los datos. Se eliminan.
El resultado: los nodos de las tareas que siguen ahí nunca se destruyen. Conservan su foco, sus transiciones y cualquier estado del navegador.
// Uso, con la misma pintarTarjeta que sirve para crear y actualizar
reconciliar(
lista,
visibles,
(tarea) => tarea.id,
(tarea, nodo) => pintarTarjeta(tarea, nodo, estado.hoy)
);Compruébalo: pon el foco en el botón «Empezar» de la tercera tarea, pulsa Enter y observa que el foco sigue ahí después del render, con el botón ya cambiado a «Marcar hecha». Con replaceChildren habría desaparecido.
Los límites de esta implementación. Es deliberadamente sencilla y no es un algoritmo de reconciliación completo: no detecta movimientos de forma óptima (puede mover más nodos de los estrictamente necesarios) y solo funciona con hijos directos que tengan clave. Para una lista de tareas es más que suficiente. Para una interfaz grande, con árboles anidados y componentes, hacer esto a mano se vuelve inviable, y ahí es donde entran los frameworks: React, Vue y Angular resuelven este problema exacto —y por eso todos ellos te piden una key en las listas, que es literalmente este data-id—. Lo verás en Por Qué Existen los Frameworks, y llegarás a esa lección sabiendo qué problema resuelven, que es la única forma de entenderlos de verdad.
- Ordenar y filtrar en la vista sin tocar el modelo
Filtrar y ordenar son decisiones de presentación. El Tablero no debe enterarse: si Lucía filtra por su nombre, el tablero sigue teniendo seis tareas y 45 h abiertas; lo único que cambia es lo que se muestra.
// js/vista/tablero-vista.js
const ORDENES = Object.freeze({
prioridad: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad] || a.id - b.id,
fecha: (a, b) => a.fechaLimite.localeCompare(b.fechaLimite),
horas: (a, b) => b.horasEstimadas - a.horasEstimadas,
titulo: (a, b) => a.titulo.localeCompare(b.titulo, 'es')
});
/** Aplica filtros y orden SIN modificar el tablero. Devuelve un array nuevo. */
function tareasVisibles(estado) {
const { responsable, texto } = estado.filtros;
return estado.tablero.tareas // ← copia defensiva del modelo (05-03)
.filter((t) => responsable === null || t.responsable === responsable)
.filter((t) => texto === '' || t.titulo.toLowerCase().includes(texto.toLowerCase()))
.sort(ORDENES[estado.orden] ?? ORDENES.prioridad);
}Tres puntos importantes:
tablero.tareasya devuelve una copia ([...this.#tareas], encapsulación de 05-03), así que el.sort(), que muta, no toca el array interno del modelo. Si el getter devolviera el array real, estesortreordenaría el modelo: un fallo sutil y desagradable.- La comparadora
prioridadusa||como desempate: si dos tareas tienen la misma prioridad, se ordenan porid. Sin desempate, el orden entre iguales dependería del algoritmo y podría cambiar entre renders, produciendo saltos visuales. localeCompare(texto, 'es')ordena correctamente los acentos y la ñ, cosa que el<de las cadenas no hace.
- Nómada Tareas:
vista/tablero-vista.js
vista/tablero-vista.jsEl módulo completo, que junta todo lo anterior:
// js/vista/tablero-vista.js
import { $, $$, reconciliar } from './dom.js';
import { pintarTarjeta } from './tarjeta.js';
import { PESOS } from '../util/formato.js';
import { HOY } from '../util/fechas.js';
import { EVENTOS, emitir } from './eventos.js';
const COLUMNAS = [
{ estado: 'pendiente', titulo: 'Pendientes' },
{ estado: 'en-curso', titulo: 'En curso' },
{ estado: 'hecha', titulo: 'Hechas' }
];
const ORDENES = Object.freeze({
prioridad: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad] || a.id - b.id,
fecha: (a, b) => a.fechaLimite.localeCompare(b.fechaLimite),
horas: (a, b) => b.horasEstimadas - a.horasEstimadas
});
export class TableroVista {
#contenedor;
#resumen;
#estado;
constructor({ contenedor, resumen, tablero, hoy = HOY }) {
this.#contenedor = contenedor;
this.#resumen = resumen;
this.#estado = {
tablero,
hoy,
filtros: { responsable: null, texto: '' },
orden: 'prioridad'
};
this.#prepararColumnas();
}
/** Las columnas se crean UNA vez; después solo se reconcilian sus listas. */
#prepararColumnas() {
this.#contenedor.replaceChildren(...COLUMNAS.map(({ estado, titulo }) => {
const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
columna.dataset.estado = estado;
const encabezado = $('.columna__titulo', columna);
encabezado.textContent = titulo;
encabezado.id = `columna-${estado}`;
columna.setAttribute('aria-labelledby', `columna-${estado}`);
$('.lista-tareas', columna).setAttribute('aria-live', 'polite');
return columna;
}));
}
/** Cambia una parte del estado de la vista y redibuja. */
actualizar(cambios) {
Object.assign(this.#estado, cambios);
if (cambios.filtros) Object.assign(this.#estado.filtros, cambios.filtros);
this.render();
}
#visibles() {
const { responsable, texto } = this.#estado.filtros;
return this.#estado.tablero.tareas
.filter((t) => responsable === null || t.responsable === responsable)
.filter((t) => texto === '' || t.titulo.toLowerCase().includes(texto.toLowerCase()))
.sort(ORDENES[this.#estado.orden] ?? ORDENES.prioridad);
}
render() {
const visibles = this.#visibles();
const porEstado = Object.groupBy(visibles, (t) => t.estado);
const hayFiltro = this.#estado.filtros.responsable !== null || this.#estado.filtros.texto !== '';
for (const { estado } of COLUMNAS) {
const columna = $(`.columna[data-estado="${estado}"]`, this.#contenedor);
const delEstado = porEstado[estado] ?? [];
const horas = delEstado.reduce((suma, t) => suma + t.horasEstimadas, 0);
$('.columna__contador', columna).textContent =
`${delEstado.length} tarea${delEstado.length === 1 ? '' : 's'} · ${horas} h`;
// Reconciliación: los nodos que siguen vivos NO se destruyen
reconciliar(
$('.lista-tareas', columna),
delEstado,
(tarea) => tarea.id,
(tarea, nodo) => pintarTarjeta(tarea, nodo, this.#estado.hoy)
);
const vacia = $('.columna__vacia', columna);
vacia.hidden = delEstado.length > 0;
vacia.textContent = hayFiltro
? 'Ninguna tarea coincide con el filtro actual.'
: 'No hay tareas en esta columna.';
}
const r = this.#estado.tablero.resumen(this.#estado.hoy);
this.#resumen.textContent =
`${r.total} tareas · ${r.abiertas} abiertas · ${r.horasAbiertas} de ${r.horasTotales} h · ` +
`${r.vencidas} vencida${r.vencidas === 1 ? '' : 's'} · esfuerzo ${r.esfuerzo}` +
(hayFiltro ? ` · mostrando ${visibles.length}` : '');
emitir(this.#contenedor, EVENTOS.TABLERO_ACTUALIZADO, { ...r, mostradas: visibles.length });
}
}Y el punto de entrada, que ya es puro cableado:
// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { TableroVista } from './vista/tablero-vista.js';
import { $ } from './vista/dom.js';
const tablero = new Tablero('Taller Nómada', crearBacklog());
const vista = new TableroVista({
contenedor: $('.tablero'),
resumen: $('#resumen'),
tablero,
hoy: HOY
});
vista.render();
// Un único manejador delegado para TODO el tablero (06-04)
$('.tablero').addEventListener('click', (evento) => {
const boton = evento.target.closest('button[data-accion]');
if (boton === null) return;
const id = Number(boton.closest('[data-id]').dataset.id);
const tarea = tablero.buscarPorId(id);
const destino = boton.dataset.accion === 'avanzar'
? { pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null }[tarea.estado]
: (tarea.estado === 'en-curso' ? 'pendiente' : null);
if (destino === null) return;
tablero.cambiarEstado(id, destino); // 1 · cambia el ESTADO
vista.render(); // 2 · redibuja
});
// Los filtros solo cambian el estado de la vista; el modelo ni se entera
$('.filtros').addEventListener('click', (evento) => {
const boton = evento.target.closest('.filtro');
if (boton === null) return;
$$('.filtro').forEach((b) => b.setAttribute('aria-pressed', String(b === boton)));
vista.actualizar({ filtros: { responsable: boton.dataset.responsable || null } });
});Todo el ciclo del apartado 2, en veinte líneas: el manejador delegado toca el modelo y llama a render(); render() dibuja lo que hay. Al marcar la carpintería como hecha, la tarjeta se mueve de la columna «Pendientes» a «Hechas» sin destruirse, los contadores pasan de 3 tareas · 25 h a 2 tareas · 20 h y de 1 tarea · 3 h a 2 tareas · 8 h, el resumen baja de 45 a 40 h abiertas y de 1 vencida a 0, y el foco sigue exactamente donde estaba.
Errores Comunes y Consejos
- Interpolar datos sin escapar en una plantilla de texto. Basta un
${}olvidado para abrir un XSS. Si eliges este enfoque, escapa siempre; si puedes, usa<template>o nodos y elimina la clase entera de errores. - Usar el índice del array como clave.
data-id="${indice}"parece funcionar hasta que se elimina un elemento del medio: entonces todos los nodos representan al dato equivocado. La clave debe pertenecer al dato. - Olvidar
?? []conObject.groupBy. Solo crea las claves que aparecen; una columna sin tareas daundefinedy el.mapsiguiente lanzaTypeError. - Ordenar el array del modelo.
sortmuta. Funciona aquí porquetablero.tareasdevuelve una copia; si algún getter devolviera el array interno, estarías reordenando el modelo desde la vista. Ante la duda,[...lista].sort(...). - Redibujar dentro de un bucle de eventos rápido. Un
render()completo por cada pulsación de tecla en un campo de búsqueda es trabajo desperdiciado. La solución es el debounce que verás en 06-07. - Poner manejadores dentro de
render. Sirenderregistra manejadores, cada llamada añade otro y acabas con la acción ejecutándose cinco veces. Los manejadores se registran una vez, por delegación, fuera del render. - Redibujar y perder el foco sin darse cuenta. Es un fallo de accesibilidad serio y silencioso: quien usa ratón no lo nota nunca. Pruébalo siempre con el tabulador.
- Consejo: mantén
render()sin efectos ocultos. Que solo lea el estado y escriba en el DOM. Si hace peticiones, cambia el modelo o dispara acciones, deja de ser predecible y depurar se vuelve muy difícil. - Consejo: guarda el estado de la vista en un solo objeto. Tener
filtros,ordenyhoyen un único#estadohace trivial responder a «¿por qué se ve esto?»: imprimes el objeto y lo sabes.
Ejercicios
Ejercicio 1 · Buscador por título
Añade al HTML un <input type="search" id="buscar"> con su <label> y conéctalo para que al escribir se filtren las tareas por título. Debe usar estado.filtros.texto, no tocar el modelo, y mostrar en el resumen cuántas se están viendo. Comprueba con el tabulador que el foco no se sale del campo de búsqueda al escribir, y explica por qué la reconciliación es imprescindible aquí.
Ejercicio 2 · Ordenación con <select>
Añade un <select id="orden"> con las opciones prioridad, fecha límite y horas, y haz que al cambiarlo se reordenen las tarjetas. Después comprueba, con las DevTools, que al reordenar los nodos se mueven en lugar de recrearse: pon un data-marca en un <li> desde la consola, reordena, y verifica que la marca sigue ahí.
Ejercicio 3 · Reconciliación con eliminación
Añade un botón data-accion="eliminar" a la tarjeta y un método eliminar(id) al Tablero. Comprueba que tras eliminar una tarea del medio, las tarjetas restantes conservan sus nodos (no se recrean) y que el foco pasa al botón equivalente de la tarea siguiente. Explica qué habría ocurrido si reconciliar usara la posición en lugar de data-id.
Soluciones
Ejercicio 1
<label for="buscar">Buscar por título</label>
<input type="search" id="buscar" placeholder="serigrafía, web, carpintería…">$('#buscar').addEventListener('input', (evento) => {
vista.actualizar({ filtros: { texto: evento.target.value.trim() } });
});La reconciliación es imprescindible aquí por un motivo muy concreto: el evento input se dispara en cada pulsación, así que cada letra provoca un render(). Si ese render hiciera replaceChildren, destruiría y recrearía todas las tarjetas veinte veces mientras Marta escribe «serigrafía». No perdería el foco del <input> —está fuera del contenedor reconciliado—, pero sí desperdiciaría muchísimo trabajo, reiniciaría las transiciones CSS en cada tecla y produciría un parpadeo perfectamente visible. Con reconciliar, cada pulsación solo elimina las tarjetas que dejan de coincidir y devuelve las que vuelven a coincidir.
Escribiendo «carpint», el resumen muestra 6 tareas · 5 abiertas · 45 de 48 h · 1 vencida · esfuerzo 124 · mostrando 1: los números del modelo no cambian, solo la cuenta de lo mostrado. Es exactamente la separación que buscábamos.
Ejercicio 2
<label for="orden">Ordenar por</label>
<select id="orden">
<option value="prioridad">Prioridad</option>
<option value="fecha">Fecha límite</option>
<option value="horas">Horas estimadas</option>
</select>$('#orden').addEventListener('change', (evento) => {
vista.actualizar({ orden: evento.target.value });
});Comprobación de que los nodos se mueven y no se recrean:
// En la consola, antes de reordenar:
document.querySelector('[data-id="6"]').dataset.marca = 'testigo';
// Cambia el <select> a "Fecha límite" y comprueba:
document.querySelector('[data-id="6"]').dataset.marca; // 'testigo' ✓ es el MISMO nodoSi la marca sobrevive, es que reconciliar reutilizó el nodo y solo lo recolocó con insertBefore. Con replaceChildren la marca habría desaparecido, porque el <li> sería otro objeto distinto. Este truco del atributo testigo es, por cierto, una técnica de depuración excelente para verificar cualquier estrategia de reutilización de nodos.
Nota: change es el evento adecuado para un <select>; input también funciona, pero change expresa mejor la intención de «ha elegido una opción».
Ejercicio 3
// modelo/tablero.js — el método nuevo, en el MODELO
eliminar(id) {
const indice = this.#tareas.findIndex((t) => t.id === id);
if (indice === -1) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
return this.#tareas.splice(indice, 1)[0];
}// app.js — dentro del manejador delegado
if (boton.dataset.accion === 'eliminar') {
const li = boton.closest('li[data-id]');
const siguiente = li.nextElementSibling ?? li.previousElementSibling;
const accion = boton.dataset.accion;
tablero.eliminar(id);
vista.render();
// El nodo 'siguiente' sobrevive al render gracias a la reconciliación
(siguiente?.querySelector(`[data-accion="${accion}"]`) ?? $('#buscar')).focus();
return;
}Si reconciliar identificara los nodos por posición, al eliminar la tarea del medio ocurriría lo siguiente: el nodo que ocupaba la posición 2 se rellenaría con los datos de la tarea que antes estaba en la 3, el de la 3 con los de la 4, y así hasta el final; el último nodo se eliminaría. Visualmente el resultado sería correcto, pero todos los nodos habrían cambiado de dato: el foco, que estaba en la tarjeta de la posición 3, acabaría en una tarjeta que ahora muestra otra tarea; cualquier transición CSS en curso se aplicaría al elemento equivocado; y cualquier estado del navegador ligado a un nodo (un <details> desplegado, por ejemplo) se atribuiría a la tarea que no era.
Con data-id como clave, el nodo de cada tarea sigue siendo suyo pase lo que pase: solo se elimina el nodo cuya clave desaparece de los datos. Es exactamente por esto que React, Vue y Angular insisten tanto en la prop key de las listas, y verlo desde dentro te va a ahorrar mucho tiempo cuando llegues al Módulo 10.
Conclusión
Has pasado de manipular nodos sueltos a tener una arquitectura. El eje es la función render(estado), que describe cómo debe verse la pantalla dado un estado, y el ciclo estado → render → evento → nuevo estado → render, con sus reglas: un solo sentido, los manejadores no tocan el DOM (cambian el estado y llaman a render), y el modelo sigue sin saber que existe una pantalla. La ventaja no es escribir menos, sino que la interfaz no puede desincronizarse de los datos, que es la clase de error más difícil de perseguir en una aplicación imperativa.
Conoces las tres maneras de producir HTML y el criterio para elegir. Los template literals son los más legibles y los más peligrosos: exigen escapar cada dato con una función escaparHtml que traduzca & < > " ', y basta un olvido para abrir un XSS. La construcción con nodos es segura por definición. Y <template> con content.cloneNode(true) combina la legibilidad de tener la estructura en el HTML con la seguridad de rellenar mediante textContent, que es la opción adoptada en el proyecto. Sobre ella has escrito pintarTarjeta(tarea, existente), una función que crea o actualiza según le pases o no un nodo previo: la propiedad que hace posible todo lo demás.
Sabes agrupar el tablero por columnas con Object.groupBy, con el recordatorio de que solo crea las claves presentes y de que hace falta ?? []; dibujar contadores por columna (3 tareas y 25 h pendientes, 2 y 20 h en curso, 1 y 3 h hechas, que suman las 45 h abiertas de siempre); y cuidar los estados vacíos con mensajes distintos según sea una columna vacía, un filtro sin resultados o un tablero recién estrenado. Y, sobre todo, sabes qué pasa cuando se redibuja todo con replaceChildren: se pierden el foco, el desplazamiento, la selección y el estado del navegador, un fallo de accesibilidad que quien usa ratón no percibe nunca. La respuesta está en las claves estables: reconciliar(contenedor, datos, clave, pintar) indexa los nodos existentes en un Map por su data-id, reutiliza los que siguen vivos actualizando su contenido, los recoloca con insertBefore y elimina los sobrantes. Los nodos supervivientes conservan su identidad, y con ella el foco y las transiciones. Filtrar y ordenar, por último, son decisiones de la vista: viven en su estado, operan sobre la copia que devuelve tablero.tareas y jamás alteran el modelo.
Ese reconciliar de veinte líneas es también una lección sobre el oficio: hacerlo bien para una lista plana con clave es asequible; hacerlo para árboles anidados de componentes, con orden arbitrario y actualizaciones parciales, es el trabajo que hacen React, Vue y Angular. Llegarás al Módulo 10 sabiendo exactamente qué problema resuelven y por qué todos te piden una key.
Queda una última pieza, y es la que convierte el tablero en una herramienta de verdad. Marta puede mover tareas y filtrarlas, pero no puede crear ninguna: el backlog sigue siendo el de datos/backlog.js. Dar de alta una tarea significa un formulario, y un formulario trae consigo un mundo propio: campos accesibles con sus etiquetas, valores que llegan siempre como texto, validación nativa del navegador frente a validación en JavaScript, reglas de negocio que deben seguir viviendo en el modelo, mensajes de error que un lector de pantalla pueda anunciar, y el foco puesto donde corresponde cuando algo falla. Todo eso es Manejo y Validación de Formularios, la última lección del módulo.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
