Skip to content

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:

  1. Agregar tareas desde un formulario (Enter o botón). No se permiten tareas vacías.
  2. Marcar y desmarcar tareas como completadas.
  3. Editar el texto con doble clic (Enter guarda, Escape cancela, dejarlo vacío borra la tarea).
  4. Eliminar tareas individuales.
  5. Filtros: todas, pendientes y completadas.
  6. Contador de tareas pendientes ("1 pendiente", "3 pendientes").
  7. Botón Limpiar completadas (sólo visible si hay alguna).
  8. Persistencia en localStorage, a prueba de errores con try/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í.

Lista de tareas

Estructura de archivos ​

txt
todo-list/
├── index.html   ← estructura y formulario
├── styles.css   ← estilos
└── app.js       ← estado, render y eventos

La 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-pressed le dice al lector de pantalla qué filtro está activo, y además lo usamos en CSS para pintarlo.
  • data-filtro guarda el valor del filtro en el propio botón; lo leeremos con dataset.

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, siempre

Seguridad: 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ónPor 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 createElementImposible inyectar HTML o scripts (XSS).
try/catch en el storageModo privado, cuota llena o JSON corrupto no rompen la app.
Prefijo academia: en la claveEvita chocar con otras apps del mismo dominio.
aria-pressed, aria-label, aria-liveLa 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 ​

  1. Recordar el filtro: guarda el filtro activo en localStorage para que se mantenga al recargar.
  2. Marcar todas: agrega un botón que marque todas como completadas (o las desmarque si ya lo están).
  3. 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 con innerHTML, para evitar XSS.
  • Envuelve localStorage y JSON.parse en try/catch y 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

Academia Web — aprende, copia y construye.