Tema
Proyecto: lista de tareas
Llegó la hora de juntar todo lo que aprendiste: DOM, eventos, formularios, arrays y storage. Vas a construir una lista de tareas completa, del tipo que podrías entregar a un cliente o poner en tu portafolio. Más que la app en sí, lo importante es cómo está organizada: con un patrón que vas a reutilizar en todos tus proyectos (y que es la base de React, Vue y compañía).
Objetivo y requisitos
Construir una app de tareas que funcione sin servidor y recuerde los datos al recargar.
Requisitos funcionales:
- Agregar tareas desde un formulario (Enter o botón). No se permiten tareas vacías.
- Marcar y desmarcar tareas como completadas.
- Editar el texto con doble clic (Enter guarda, Escape cancela, dejarlo vacío borra la tarea).
- Eliminar tareas individuales.
- Filtros: todas, pendientes y completadas.
- Contador de tareas pendientes ("1 pendiente", "3 pendientes").
- Botón Limpiar completadas (sólo visible si hay alguna).
- Persistencia en
localStorage, a prueba de errores contry/catch.
Requisitos técnicos: sin librerías, accesible con teclado y lector de pantalla, y seguro contra inyección de HTML.
Demo final
Pruébala: agrega tareas, edítalas con doble clic, filtra y recarga la página. Tus tareas siguen ahí.
Estructura de archivos
txt
todo-list/
├── index.html ← estructura y formulario
├── styles.css ← estilos
└── app.js ← estado, render y eventosLa idea clave: estado + render
Los principiantes suelen modificar el DOM "a mano" en cada acción: crear un <li> al agregar, buscarlo para tacharlo, buscarlo otra vez para borrarlo... Funciona con tres tareas y se vuelve un caos con filtros, contador y edición, porque el DOM y los datos se desincronizan.
El enfoque profesional separa responsabilidades:
txt
Evento del usuario ──► Acción (modifica el ESTADO) ──► guardar() ──► render()
▲ │
└──────────────────── el DOM refleja el estado ◄────────────────┘- El estado es la única fuente de verdad: un objeto con las tareas, el filtro activo y qué tarea se está editando.
- Las acciones sólo cambian el estado (no tocan el DOM).
render()borra la lista y la vuelve a pintar completa a partir del estado. Es lo que hacen React o Vue por ti (con optimizaciones).
Paso 1: el HTML
Un formulario (para que Enter funcione gratis), botones de filtro, la lista vacía y un pie con el botón de limpiar. Las tareas no se escriben en el HTML: las pinta JavaScript. (El demo junta los tres archivos en uno, pero el código es el mismo.)
html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lista de tareas</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="app">
<header class="app__header">
<h1>Mis tareas</h1>
<p class="contador" id="contador" aria-live="polite">0 pendientes</p>
</header>
<form class="nueva" id="form-nueva">
<label for="input-nueva" class="sr-only">Nueva tarea</label>
<input id="input-nueva" type="text" placeholder="¿Qué necesitas hacer?" maxlength="120" autocomplete="off">
<button type="submit">Agregar</button>
</form>
<nav class="filtros" id="filtros" aria-label="Filtrar tareas">
<button type="button" data-filtro="todas" aria-pressed="true">Todas</button>
<button type="button" data-filtro="pendientes" aria-pressed="false">Pendientes</button>
<button type="button" data-filtro="completadas" aria-pressed="false">Completadas</button>
</nav>
<ul class="lista" id="lista"></ul>
<p class="vacio" id="vacio" hidden>No hay tareas aquí ✨</p>
<footer class="app__footer">
<span>Doble clic para editar</span>
<button type="button" class="link" id="btn-limpiar">Limpiar completadas</button>
</footer>
</main>
</body>
</html>- El
<label>existe aunque esté oculto visualmente (.sr-only): los lectores de pantalla lo anuncian. aria-pressedle dice al lector de pantalla qué filtro está activo, y además lo usamos en CSS para pintarlo.data-filtroguarda el valor del filtro en el propio botón; lo leeremos condataset.
Paso 2: los estilos
Nada exótico: Flexbox para alinear, una tarjeta centrada y dos trucos que vale la pena notar. La clase .sr-only oculta el label visualmente sin quitárselo a los lectores de pantalla, y el filtro activo se pinta con el selector de atributo [aria-pressed="true"]: el mismo atributo sirve para accesibilidad y para estilos, así nunca se desincronizan.
css
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; padding: 16px; font-family: system-ui, sans-serif; background: linear-gradient(135deg, #ede9fe, #e0f2fe); color: #0f172a; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.app { max-width: 520px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgb(15 23 42 / .08); }
.app__header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 14px; }
h1 { margin: 0; font-size: 22px; }
.contador { margin: 0; font-size: 13px; color: #64748b; }
.nueva { display: flex; gap: 8px; }
.nueva input { flex: 1; min-width: 0; padding: 11px 12px; border: 1px solid #cbd5e1; border-radius: 10px; font-size: 15px; }
.nueva input:focus, .tarea__editar:focus { outline: 2px solid #7c3aed; outline-offset: 1px; border-color: transparent; }
button { font: inherit; cursor: pointer; }
.nueva button { border: 0; border-radius: 10px; padding: 0 16px; background: #7c3aed; color: #fff; font-weight: 600; }
.nueva button:hover { background: #6d28d9; }
.filtros { display: flex; gap: 6px; margin: 14px 0 8px; }
.filtros button { border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 999px; padding: 6px 12px; font-size: 13px; color: #475569; }
.filtros button[aria-pressed="true"] { background: #ede9fe; border-color: #c4b5fd; color: #5b21b6; font-weight: 600; }
.lista { list-style: none; margin: 0; padding: 0; max-height: 230px; overflow-y: auto; }
.tarea { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #f1f5f9; }
.tarea__check { width: 18px; height: 18px; accent-color: #7c3aed; flex-shrink: 0; }
.tarea__texto { flex: 1; word-break: break-word; cursor: text; border-radius: 4px; }
.tarea__texto:focus-visible { outline: 2px solid #7c3aed; }
.tarea--hecha .tarea__texto { text-decoration: line-through; color: #94a3b8; }
.tarea__editar { flex: 1; padding: 6px 8px; border: 1px solid #c4b5fd; border-radius: 8px; font-size: 15px; }
.tarea__borrar { border: 0; background: transparent; color: #94a3b8; width: 28px; height: 28px; border-radius: 8px; }
.tarea__borrar:hover, .tarea__borrar:focus-visible { background: #fee2e2; color: #b91c1c; }
.vacio { text-align: center; color: #94a3b8; padding: 24px 0; margin: 0; }
.app__footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; font-size: 12px; color: #94a3b8; }
.link { border: 0; background: none; color: #7c3aed; padding: 4px 0; font-size: 13px; }
.link:hover { text-decoration: underline; }Paso 3: el estado y la persistencia
js
const CLAVE = 'academia:tareas';
const estado = {
tareas: cargarTareas(),
filtro: 'todas', // 'todas' | 'pendientes' | 'completadas'
editandoId: null // id de la tarea en modo edición
};Cada tarea es un objeto { id, texto, completada }. guardarTareas() convierte el array a JSON y lo escribe dentro de try/catch (modo privado o cuota llena no deben tumbar la app). cargarTareas() lee el storage dentro de try/catch y valida con Array.isArray: si alguien editó el valor a mano en DevTools y dejó JSON roto, la app arranca vacía en lugar de romperse (repasa Storage y JSON).
Error común: usar el índice como id
Si identificas las tareas por su posición en el array, al filtrar o borrar los índices cambian y terminas completando la tarea equivocada. Dale a cada tarea un id único y estable desde que se crea.
Paso 4: las acciones
Funciones pequeñas que sólo modifican estado.tareas. Fíjate que ninguna toca el DOM:
js
function alternarTarea(id) {
const tarea = estado.tareas.find((t) => t.id === id);
if (tarea) tarea.completada = !tarea.completada;
}
function actualizar() {
guardarTareas();
render();
}actualizar() es el "después de cada cambio": guarda y vuelve a pintar.
Paso 5: render seguro con textContent
render() calcula las tareas visibles según el filtro, crea un <li> por cada una y reemplaza todo el contenido de la lista con replaceChildren. También actualiza el contador, el botón de limpiar y los filtros.
El punto más importante de seguridad está en crearItem:
js
const texto = document.createElement('span');
texto.textContent = tarea.texto; // ✅ se muestra como texto, siempreSeguridad: nunca metas texto del usuario con innerHTML
Si hicieras li.innerHTML = tarea.texto y alguien escribiera <img src=x onerror="alert('hackeado')">, ese código se ejecutaría. Eso es un ataque XSS. Con datos guardados en localStorage es aún peor: se ejecuta cada vez que alguien abre la página. Usa textContent (o createElement) para cualquier dato que venga del usuario o de una API.
Paso 6: delegación de eventos
Las tareas se crean y destruyen en cada render, así que poner listeners a cada botón sería un desastre (se perderían y habría que volver a ponerlos). En su lugar, un solo listener por tipo de evento en la <ul> que pregunta dónde ocurrió (repasa Eventos):
js
lista.addEventListener('click', (e) => {
const boton = e.target.closest('[data-accion="eliminar"]');
if (boton) {
eliminarTarea(idDe(boton));
actualizar();
}
});
// idDe sube hasta el <li> y lee su data-id
const idDe = (elemento) => elemento.closest('.tarea').dataset.id;Usamos change para el checkbox (se dispara también con la barra espaciadora), dblclick para entrar a edición y keydown para Enter y Escape.
Paso 7: edición con doble clic
Entrar a edición es sólo cambiar el estado: estado.editandoId = id y render(). Al pintar, si una tarea está en edición, crearItem devuelve un <input> en lugar del texto, y render() le pone el foco.
Para salir hay tres caminos: Enter, Escape o hacer clic fuera (focusout). El truco está en evitar que se guarde dos veces: terminarEdicion empieza con if (estado.editandoId === null) return; y pone editandoId en null antes de guardar. Al presionar Enter, render() quita el <input> del DOM, y algunos navegadores disparan focusout en ese momento. Como editandoId ya es null, la segunda llamada no hace nada.
Accesibilidad: el doble clic no existe en el teclado
Por eso el texto de cada tarea tiene tabIndex = 0 (se puede enfocar con Tab) y Enter también abre la edición. Además, los botones de eliminar llevan aria-label con el nombre de la tarea: un lector de pantalla dirá "Eliminar: Comprar leche" y no sólo "botón ✕". El contador tiene aria-live="polite" para que los cambios se anuncien.
Código completo: app.js
El HTML y el CSS ya los tienes completos en los pasos 1 y 2. Este es el JavaScript final, idéntico al del demo. Con defer en la etiqueta <script> el archivo se ejecuta cuando el HTML ya está listo, igual que poner el script al final del <body>.
js
// ===== Configuración y estado =====
const CLAVE = 'academia:tareas';
const TAREAS_EJEMPLO = [
{ id: 'a1', texto: 'Repasar eventos y delegación', completada: true },
{ id: 'a2', texto: 'Construir la lista de tareas', completada: false },
{ id: 'a3', texto: 'Publicar el proyecto en GitHub', completada: false }
];
const estado = {
tareas: cargarTareas(),
filtro: 'todas', // 'todas' | 'pendientes' | 'completadas'
editandoId: null // id de la tarea en modo edición
};
// ===== Referencias al DOM =====
const form = document.querySelector('#form-nueva');
const input = document.querySelector('#input-nueva');
const lista = document.querySelector('#lista');
const filtros = document.querySelector('#filtros');
const contador = document.querySelector('#contador');
const vacio = document.querySelector('#vacio');
const btnLimpiar = document.querySelector('#btn-limpiar');
// ===== Persistencia =====
function cargarTareas() {
try {
const texto = localStorage.getItem(CLAVE);
if (texto === null) return TAREAS_EJEMPLO;
const datos = JSON.parse(texto);
return Array.isArray(datos) ? datos : [];
} catch (error) {
console.warn('No se pudieron leer las tareas', error);
return [];
}
}
function guardarTareas() {
try {
localStorage.setItem(CLAVE, JSON.stringify(estado.tareas));
} catch (error) {
console.warn('No se pudieron guardar las tareas', error);
}
}
// ===== Acciones: sólo modifican el estado =====
const crearId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
function agregarTarea(texto) {
estado.tareas.unshift({ id: crearId(), texto, completada: false });
}
function alternarTarea(id) {
const tarea = estado.tareas.find((t) => t.id === id);
if (tarea) tarea.completada = !tarea.completada;
}
function editarTarea(id, texto) {
const tarea = estado.tareas.find((t) => t.id === id);
if (tarea) tarea.texto = texto;
}
function eliminarTarea(id) {
estado.tareas = estado.tareas.filter((t) => t.id !== id);
}
function limpiarCompletadas() {
estado.tareas = estado.tareas.filter((t) => !t.completada);
}
// Guarda y vuelve a pintar: se llama después de cada cambio
function actualizar() {
guardarTareas();
render();
}
// ===== Render: pinta el DOM a partir del estado =====
function tareasVisibles() {
if (estado.filtro === 'pendientes') return estado.tareas.filter((t) => !t.completada);
if (estado.filtro === 'completadas') return estado.tareas.filter((t) => t.completada);
return estado.tareas;
}
function crearItem(tarea) {
const li = document.createElement('li');
li.className = tarea.completada ? 'tarea tarea--hecha' : 'tarea';
li.dataset.id = tarea.id;
if (estado.editandoId === tarea.id) {
const campo = document.createElement('input');
campo.className = 'tarea__editar';
campo.value = tarea.texto;
campo.maxLength = 120;
campo.setAttribute('aria-label', 'Editar tarea (Enter guarda, Escape cancela)');
li.append(campo);
return li;
}
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'tarea__check';
check.checked = tarea.completada;
check.setAttribute('aria-label', `Completar: ${tarea.texto}`);
const texto = document.createElement('span');
texto.className = 'tarea__texto';
texto.textContent = tarea.texto; // textContent: nunca interpreta HTML
texto.tabIndex = 0; // enfocable para editar con Enter
texto.title = 'Doble clic o Enter para editar';
const borrar = document.createElement('button');
borrar.type = 'button';
borrar.className = 'tarea__borrar';
borrar.dataset.accion = 'eliminar';
borrar.setAttribute('aria-label', `Eliminar: ${tarea.texto}`);
borrar.textContent = '✕';
li.append(check, texto, borrar);
return li;
}
function render() {
const visibles = tareasVisibles();
lista.replaceChildren(...visibles.map(crearItem));
vacio.hidden = visibles.length > 0;
const pendientes = estado.tareas.filter((t) => !t.completada).length;
contador.textContent = `${pendientes} ${pendientes === 1 ? 'pendiente' : 'pendientes'}`;
btnLimpiar.hidden = !estado.tareas.some((t) => t.completada);
for (const boton of filtros.querySelectorAll('[data-filtro]')) {
boton.setAttribute('aria-pressed', boton.dataset.filtro === estado.filtro);
}
const campo = lista.querySelector('.tarea__editar');
if (campo) {
campo.focus();
campo.select();
}
}
// ===== Edición =====
const idDe = (elemento) => elemento.closest('.tarea').dataset.id;
function iniciarEdicion(id) {
estado.editandoId = id;
render();
}
function terminarEdicion(campo, guardar = true) {
if (estado.editandoId === null) return; // evita guardar dos veces
const id = estado.editandoId;
estado.editandoId = null;
const texto = campo.value.trim();
if (guardar) {
if (texto) editarTarea(id, texto);
else eliminarTarea(id); // texto vacío = borrar
}
actualizar();
}
// ===== Eventos (delegación en la lista) =====
form.addEventListener('submit', (e) => {
e.preventDefault();
const texto = input.value.trim();
if (!texto) return input.focus();
agregarTarea(texto);
input.value = '';
if (estado.filtro === 'completadas') estado.filtro = 'todas';
actualizar();
});
lista.addEventListener('change', (e) => {
if (e.target.matches('.tarea__check')) {
alternarTarea(idDe(e.target));
actualizar();
}
});
lista.addEventListener('click', (e) => {
const boton = e.target.closest('[data-accion="eliminar"]');
if (boton) {
eliminarTarea(idDe(boton));
actualizar();
}
});
lista.addEventListener('dblclick', (e) => {
if (e.target.matches('.tarea__texto')) iniciarEdicion(idDe(e.target));
});
lista.addEventListener('keydown', (e) => {
if (e.target.matches('.tarea__texto') && e.key === 'Enter') {
iniciarEdicion(idDe(e.target));
} else if (e.target.matches('.tarea__editar')) {
if (e.key === 'Enter') terminarEdicion(e.target);
if (e.key === 'Escape') terminarEdicion(e.target, false);
}
});
lista.addEventListener('focusout', (e) => {
if (e.target.matches('.tarea__editar')) terminarEdicion(e.target);
});
filtros.addEventListener('click', (e) => {
const boton = e.target.closest('[data-filtro]');
if (!boton) return;
estado.filtro = boton.dataset.filtro;
render();
});
btnLimpiar.addEventListener('click', () => {
limpiarCompletadas();
actualizar();
});
// ===== Inicio =====
render();Decisiones de diseño
| Decisión | Por qué |
|---|---|
Estado + render() | Una sola fuente de verdad; el DOM nunca se desincroniza de los datos. |
Delegación en la <ul> | Cuatro listeners en total, sin importar si hay 3 o 3 000 tareas. |
textContent y createElement | Imposible inyectar HTML o scripts (XSS). |
try/catch en el storage | Modo privado, cuota llena o JSON corrupto no rompen la app. |
Prefijo academia: en la clave | Evita chocar con otras apps del mismo dominio. |
aria-pressed, aria-label, aria-live | La app es usable con teclado y lector de pantalla. |
Buena práctica: re-render completo está bien (al principio)
Repintar toda la lista en cada cambio parece costoso, pero con cientos de elementos es imperceptible. Optimiza sólo cuando midas un problema real. Cuando lo tengas, ahí es donde los frameworks con DOM virtual brillan.
Retos de mejora
- Recordar el filtro: guarda el filtro activo en
localStoragepara que se mantenga al recargar. - Marcar todas: agrega un botón que marque todas como completadas (o las desmarque si ya lo están).
- Sincronizar pestañas: si abres la app en dos pestañas, que los cambios de una aparezcan en la otra.
Ver solución
js
// 1. Recordar el filtro
const CLAVE_FILTRO = 'academia:filtro';
try {
estado.filtro = localStorage.getItem(CLAVE_FILTRO) ?? 'todas';
} catch { /* se queda en 'todas' */ }
// en el listener de filtros, justo después de cambiar estado.filtro:
try { localStorage.setItem(CLAVE_FILTRO, estado.filtro); } catch { /* sin storage */ }
// 2. Marcar todas (botón con id="btn-todas")
document.querySelector('#btn-todas').addEventListener('click', () => {
const todasHechas = estado.tareas.every((t) => t.completada);
estado.tareas.forEach((t) => { t.completada = !todasHechas; });
actualizar();
});
// 3. Sincronizar pestañas: el evento storage llega a las OTRAS pestañas
window.addEventListener('storage', (e) => {
if (e.key !== CLAVE) return;
estado.tareas = cargarTareas();
render(); // sólo render: no guardes otra vez o crearías un bucle
});Resumen
- Organiza tus apps con estado + acciones + render: los eventos cambian datos y
render()pinta el DOM a partir de ellos. - Usa delegación de eventos en el contenedor para listas que cambian constantemente.
- Muestra texto del usuario con
textContent, nunca coninnerHTML, para evitar XSS. - Envuelve
localStorageyJSON.parseentry/catchy valida lo que lees. - Da a cada elemento un id estable, no uses índices.
- La accesibilidad es parte del trabajo: labels,
aria-*y alternativas de teclado al doble clic.
Siguiente: Proyecto: buscador con API