Proyectos · JavaScript · Intermedio

Lista de tareas que se guarda

La aplicación clásica para aprender JavaScript de verdad: añadir, marcar, borrar y filtrar tareas, con los datos guardados en el navegador para que sigan ahí mañana.

  • 5 pasos
  • 1 hora y media aproximadamente
  • 205 líneas de código
  • Editor en vivo

Lo que vas a construir

La lista de tareas es el «hola, mundo» de las aplicaciones: parece sencilla, pero obliga a resolver lo que tiene cualquier app real. Vas a guardar los datos en un array, dibujar la página a partir de ellos cada vez que cambian y conservarlos en el navegador con localStorage.

Lo que vas a practicar

  • Formularios y preventDefault
  • Crear elementos sin innerHTML
  • Estado en un array de objetos
  • Delegación de eventos
  • Guardar con localStorage y JSON
Resultado final

Antes de empezar, conviene haber hecho estas lecciones

Paso 1 de 5

El formulario y los estilos

El HTML tiene un formulario con un campo y un botón, una lista vacía donde aparecerán las tareas y un párrafo para el resumen. La etiqueta del campo está oculta a la vista pero la leen los lectores de pantalla.

Para centrarte en JavaScript, los estilos ya vienen completos, incluidos los de las partes que añadirás en los siguientes pasos.

index.html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mis tareas</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="app">
    <h1>Mis tareas</h1>
    <form id="formulario" class="nueva">
      <label for="texto" class="oculto">Nueva tarea</label>
      <input id="texto" name="texto" placeholder="¿Qué tienes que hacer?" autocomplete="off" required maxlength="120">
      <button type="submit">Añadir</button>
    </form>
    <ul id="lista" class="lista"></ul>
    <p id="resumen" class="resumen" aria-live="polite"></p>
  </main>
  <script src="app.js"></script>
</body>
</html>
estilos.css
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  background: #eef2ff;
  color: #1e1b4b;
  font-family: system-ui, sans-serif;
}

.app {
  max-width: 480px;
  margin: 0 auto;
  padding: 32px 16px;
}

h1 { margin: 0 0 16px; }

.oculto {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.nueva { display: flex; gap: 8px; }

.nueva input {
  flex: 1;
  padding: 12px;
  border: 2px solid #c7d2fe;
  border-radius: 10px;
  font: inherit;
}

button {
  padding: 10px 14px;
  border: 0;
  border-radius: 10px;
  background: #4f46e5;
  color: #ffffff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

button:focus-visible,
input:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }

.lista { margin: 16px 0 0; padding: 0; list-style: none; }

.tarea {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  margin-bottom: 8px;
  border-radius: 10px;
  background: #ffffff;
}

.tarea label { flex: 1; display: flex; align-items: center; gap: 10px; cursor: pointer; }
.tarea input { width: 1.2rem; height: 1.2rem; accent-color: #4f46e5; }
.tarea.hecha span { color: #94a3b8; text-decoration: line-through; }
.tarea .borrar { padding: 4px 10px; background: #fee2e2; color: #991b1b; }

.filtros { display: flex; gap: 6px; margin-top: 16px; }
.filtros button { background: #e0e7ff; color: #3730a3; }
.filtros button[aria-pressed="true"] { background: #4f46e5; color: #ffffff; }

.resumen { color: #4338ca; }
.limpiar { background: transparent; color: #4338ca; padding-left: 0; }
app.js
// El JavaScript llega en el paso 2

Paso 2 de 5

Añadir tareas

Al enviar el formulario se ejecuta el evento submit. event.preventDefault() evita que la página se recargue, se lee el texto y se crea un <li> nuevo con document.createElement.

El texto se pone con textContent, que lo trata siempre como texto. Con innerHTML, alguien podría escribir HTML o un script y se ejecutaría en la página.

app.js
const formulario = document.querySelector("#formulario");
const campo = document.querySelector("#texto");
const lista = document.querySelector("#lista");

formulario.addEventListener("submit", (event) => {
  event.preventDefault(); // que la página no se recargue
  const texto = campo.value.trim();
  if (!texto) return;

  const elemento = document.createElement("li");
  elemento.className = "tarea";
  elemento.textContent = texto; // textContent, nunca innerHTML con lo que escribe la persona
  lista.append(elemento);

  campo.value = "";
  campo.focus();
});

Paso 3 de 5

Marcar y borrar: el estado manda

Para marcar y borrar hace falta recordar las tareas. En lugar de leerlas del HTML, se guardan en un array de objetos (tareas) y una función pintar() vuelve a dibujar la lista entera a partir de él cada vez que algo cambia. Es la misma idea en la que se basan React o Vue.

Cada tarea tiene un id (la hora en milisegundos) que se guarda en data-id. Con un solo escuchador en la lista se sabe qué tarea se marcó o se borró (delegación de eventos).

app.jsLo nuevo, resaltado
const formulario = document.querySelector("#formulario");
const campo = document.querySelector("#texto");
const lista = document.querySelector("#lista");
const resumen = document.querySelector("#resumen");

// El estado: una lista de objetos. La página siempre se dibuja a partir de él.
let tareas = [];

function pintar() {
  lista.replaceChildren();
  for (const tarea of tareas) {
    const elemento = document.createElement("li");
    elemento.className = tarea.hecha ? "tarea hecha" : "tarea";
    elemento.dataset.id = tarea.id;

    const etiqueta = document.createElement("label");
    const casilla = document.createElement("input");
    casilla.type = "checkbox";
    casilla.checked = tarea.hecha;
    const texto = document.createElement("span");
    texto.textContent = tarea.texto;
    etiqueta.append(casilla, texto);

    const borrar = document.createElement("button");
    borrar.type = "button";
    borrar.className = "borrar";
    borrar.textContent = "Borrar";
    borrar.setAttribute("aria-label", `Borrar «${tarea.texto}»`);

    elemento.append(etiqueta, borrar);
    lista.append(elemento);
  }
  const pendientes = tareas.filter((t) => !t.hecha).length;
  resumen.textContent = `${pendientes} pendientes de ${tareas.length}`;
}

formulario.addEventListener("submit", (event) => {
  event.preventDefault();
  const texto = campo.value.trim();
  if (!texto) return;
  tareas.push({ id: Date.now(), texto, hecha: false });
  campo.value = "";
  campo.focus();
  pintar();
});

// Un solo escuchador para todas las tareas (delegación de eventos)
lista.addEventListener("change", (event) => {
  const id = Number(event.target.closest(".tarea").dataset.id);
  const tarea = tareas.find((t) => t.id === id);
  tarea.hecha = event.target.checked;
  pintar();
});

lista.addEventListener("click", (event) => {
  if (!event.target.matches(".borrar")) return;
  const id = Number(event.target.closest(".tarea").dataset.id);
  tareas = tareas.filter((t) => t.id !== id);
  pintar();
});

pintar();

Paso 4 de 5

Que se guarde al cerrar

localStorage guarda texto en el navegador de forma permanente. Como solo admite texto, el array se convierte con JSON.stringify al guardar y con JSON.parse al cargar. Si no hay nada guardado, ?? [] empieza con la lista vacía.

Las dos operaciones van dentro de try…catch porque pueden fallar (modo privado, almacenamiento bloqueado). En la vista previa del editor no se guarda nada, porque está aislada del resto del navegador: descarga los archivos y ábrelos para probarlo de verdad.

app.jsLo nuevo, resaltado
const formulario = document.querySelector("#formulario");
const campo = document.querySelector("#texto");
const lista = document.querySelector("#lista");
const resumen = document.querySelector("#resumen");

const CLAVE = "mis-tareas";

// El estado: una lista de objetos. La página siempre se dibuja a partir de él.
let tareas = cargar();

function cargar() {
  try {
    return JSON.parse(localStorage.getItem(CLAVE)) ?? [];
  } catch {
    return []; // almacenamiento bloqueado o datos dañados: se empieza de cero
  }
}

function guardar() {
  try {
    localStorage.setItem(CLAVE, JSON.stringify(tareas));
  } catch {
    // modo privado o almacenamiento lleno: la lista funciona igual, pero no se guarda
  }
}

function pintar() {
  lista.replaceChildren();
  for (const tarea of tareas) {
    const elemento = document.createElement("li");
    elemento.className = tarea.hecha ? "tarea hecha" : "tarea";
    elemento.dataset.id = tarea.id;

    const etiqueta = document.createElement("label");
    const casilla = document.createElement("input");
    casilla.type = "checkbox";
    casilla.checked = tarea.hecha;
    const texto = document.createElement("span");
    texto.textContent = tarea.texto;
    etiqueta.append(casilla, texto);

    const borrar = document.createElement("button");
    borrar.type = "button";
    borrar.className = "borrar";
    borrar.textContent = "Borrar";
    borrar.setAttribute("aria-label", `Borrar «${tarea.texto}»`);

    elemento.append(etiqueta, borrar);
    lista.append(elemento);
  }
  const pendientes = tareas.filter((t) => !t.hecha).length;
  resumen.textContent = `${pendientes} pendientes de ${tareas.length}`;
  guardar(); // cada vez que se dibuja, se guarda
}

formulario.addEventListener("submit", (event) => {
  event.preventDefault();
  const texto = campo.value.trim();
  if (!texto) return;
  tareas.push({ id: Date.now(), texto, hecha: false });
  campo.value = "";
  campo.focus();
  pintar();
});

// Un solo escuchador para todas las tareas (delegación de eventos)
lista.addEventListener("change", (event) => {
  const id = Number(event.target.closest(".tarea").dataset.id);
  const tarea = tareas.find((t) => t.id === id);
  tarea.hecha = event.target.checked;
  pintar();
});

lista.addEventListener("click", (event) => {
  if (!event.target.matches(".borrar")) return;
  const id = Number(event.target.closest(".tarea").dataset.id);
  tareas = tareas.filter((t) => t.id !== id);
  pintar();
});

pintar();

Paso 5 de 5

Filtros y limpieza

Tres botones cambian qué tareas se ven. La variable filtro guarda el elegido y el objeto FILTROS tiene una función por filtro, que se pasa directamente a tareas.filter(...). aria-pressed indica cuál está activo.

El botón «Borrar las hechas» solo aparece cuando hay alguna, y elimina de una vez las completadas.

index.htmlLo nuevo, resaltado
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mis tareas</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="app">
    <h1>Mis tareas</h1>
    <form id="formulario" class="nueva">
      <label for="texto" class="oculto">Nueva tarea</label>
      <input id="texto" name="texto" placeholder="¿Qué tienes que hacer?" autocomplete="off" required maxlength="120">
      <button type="submit">Añadir</button>
    </form>
    <div class="filtros" role="group" aria-label="Mostrar">
      <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="hechas" aria-pressed="false">Hechas</button>
    </div>
    <ul id="lista" class="lista"></ul>
    <p id="resumen" class="resumen" aria-live="polite"></p>
    <button type="button" id="limpiar" class="limpiar">Borrar las hechas</button>
  </main>
  <script src="app.js"></script>
</body>
</html>
app.jsLo nuevo, resaltado
const formulario = document.querySelector("#formulario");
const campo = document.querySelector("#texto");
const lista = document.querySelector("#lista");
const resumen = document.querySelector("#resumen");
const botonesFiltro = document.querySelectorAll("[data-filtro]");
const limpiar = document.querySelector("#limpiar");

let filtro = "todas";
const FILTROS = {
  todas: () => true,
  pendientes: (t) => !t.hecha,
  hechas: (t) => t.hecha,
};

const CLAVE = "mis-tareas";

// El estado: una lista de objetos. La página siempre se dibuja a partir de él.
let tareas = cargar();

function cargar() {
  try {
    return JSON.parse(localStorage.getItem(CLAVE)) ?? [];
  } catch {
    return []; // almacenamiento bloqueado o datos dañados: se empieza de cero
  }
}

function guardar() {
  try {
    localStorage.setItem(CLAVE, JSON.stringify(tareas));
  } catch {
    // modo privado o almacenamiento lleno: la lista funciona igual, pero no se guarda
  }
}

function pintar() {
  lista.replaceChildren();
  for (const tarea of tareas.filter(FILTROS[filtro])) {
    const elemento = document.createElement("li");
    elemento.className = tarea.hecha ? "tarea hecha" : "tarea";
    elemento.dataset.id = tarea.id;

    const etiqueta = document.createElement("label");
    const casilla = document.createElement("input");
    casilla.type = "checkbox";
    casilla.checked = tarea.hecha;
    const texto = document.createElement("span");
    texto.textContent = tarea.texto;
    etiqueta.append(casilla, texto);

    const borrar = document.createElement("button");
    borrar.type = "button";
    borrar.className = "borrar";
    borrar.textContent = "Borrar";
    borrar.setAttribute("aria-label", `Borrar «${tarea.texto}»`);

    elemento.append(etiqueta, borrar);
    lista.append(elemento);
  }
  const pendientes = tareas.filter((t) => !t.hecha).length;
  resumen.textContent = `${pendientes} pendientes de ${tareas.length}`;
  limpiar.hidden = !tareas.some((t) => t.hecha);
  guardar(); // cada vez que se dibuja, se guarda
}

formulario.addEventListener("submit", (event) => {
  event.preventDefault();
  const texto = campo.value.trim();
  if (!texto) return;
  tareas.push({ id: Date.now(), texto, hecha: false });
  campo.value = "";
  campo.focus();
  pintar();
});

// Un solo escuchador para todas las tareas (delegación de eventos)
lista.addEventListener("change", (event) => {
  const id = Number(event.target.closest(".tarea").dataset.id);
  const tarea = tareas.find((t) => t.id === id);
  tarea.hecha = event.target.checked;
  pintar();
});

for (const boton of botonesFiltro) {
  boton.addEventListener("click", () => {
    filtro = boton.dataset.filtro;
    for (const b of botonesFiltro) b.setAttribute("aria-pressed", String(b === boton));
    pintar();
  });
}

limpiar.addEventListener("click", () => {
  tareas = tareas.filter((t) => !t.hecha);
  pintar();
});

lista.addEventListener("click", (event) => {
  if (!event.target.matches(".borrar")) return;
  const id = Number(event.target.closest(".tarea").dataset.id);
  tareas = tareas.filter((t) => t.id !== id);
  pintar();
});

pintar();

Retos para seguir

Has terminado el proyecto. Si quieres ir más allá, prueba a añadirle esto por tu cuenta:

  1. Permite editar una tarea con doble clic sobre su texto.
  2. Añade una fecha límite a cada tarea y resalta las que ya han vencido.
  3. Haz que las tareas se puedan reordenar arrastrándolas (atributo draggable y eventos de arrastre).

Conceptos clave

Repasa la ruta de JavaScript o elige otro proyecto.