Saltar al contenido
proyecto-app.js · devschool

Proyecto: una app en JavaScript

Lección 35 de 38 · 9 min de lectura · Actualizado el

En esta lección
  1. La idea clave: estado y render
  2. Paso 1: el HTML
  3. Paso 2: el estado
  4. Paso 3: los cálculos
  5. Paso 4: el render
  6. Paso 5: acciones y eventos
  7. Paso 6: guardar en localStorage
  8. Cómo queda organizado el código
  9. Cuidado con…
  10. Ideas para mejorarla
  11. Resumen

Ya conoces las piezas sueltas: arrays de objetos, métodos de arrays, crear elementos, eventos, formularios y localStorage. En este proyecto las juntas todas en una aplicación de verdad: un registro de gastos personales. Añades gastos con un formulario, los ves en una lista, filtras por categoría, ves el total y todo se guarda en el navegador. Lo más valioso no es la app en sí, sino la forma de organizarla, que es la misma que usan React, Vue y compañía.

Pulsa Ejecutar en el editor para ver el resultado final. A continuación lo construimos paso a paso.

La idea clave: estado y render

Cuando empiezas, lo natural es tocar el DOM directamente en cada evento: al añadir, crear un <li>; al borrar, quitarlo; al filtrar, ocultar algunos… Con tres funcionalidades ya es un lío, porque la información está repartida por la página y es fácil que la lista, el total y el filtro se desincronicen.

La alternativa profesional es separar dos cosas:

  1. El estado: un objeto de JavaScript con todos los datos de la app. Es la única fuente de verdad.
  2. El render: una función que dibuja la página entera a partir del estado.

Y una regla: los eventos nunca tocan el DOM, solo cambian el estado y llaman a render().

  usuario hace algo  →  acción cambia el ESTADO  →  guardar()  →  render() pinta la página
         ↑                                                                   │
         └───────────────────────────────────────────────────────────────────┘

Así, la pantalla siempre refleja el estado. Si algo se ve mal, sabes que el fallo está en el estado o en render, no en veinte sitios distintos.

Paso 1: el HTML

Solo la estructura fija: el formulario, el selector de filtro, una lista vacía y el total. El contenido de la lista lo pondrá JavaScript.

<h1>Mis gastos</h1>

<form id="formulario">
  <input id="concepto" placeholder="Concepto" required>
  <input id="importe" type="number" min="0.01" step="0.01" placeholder="€" required>
  <select id="categoria">
    <option value="comida">Comida</option>
    <option value="transporte">Transporte</option>
    <option value="ocio">Ocio</option>
    <option value="casa">Casa</option>
  </select>
  <button>Añadir</button>
</form>

<label>Ver
  <select id="filtro">
    <option value="todas">Todas las categorías</option>
    <option value="comida">Comida</option>
    <!-- ...las mismas categorías -->
  </select>
</label>

<ul id="lista"></ul>
<p id="vacio">No hay gastos en esta categoría.</p>
<p>Total: <strong id="total"></strong></p>

Los atributos required, min y step hacen que el navegador valide antes de enviar. Aun así, validaremos también en JavaScript.

Paso 2: el estado

Cada gasto es un objeto, y el estado guarda la lista de gastos y el filtro elegido:

const estado = {
  gastos: [
    { id: 1, concepto: "Menú del día", importe: 12.5, categoria: "comida", fecha: "2026-09-28" },
    { id: 2, concepto: "Abono transporte", importe: 20, categoria: "transporte", fecha: "2026-09-29" },
  ],
  filtro: "todas",
};

Tres decisiones importantes:

  • Cada gasto tiene un id único. Para borrar uno necesitas identificarlo sin ambigüedad; el concepto se puede repetir. Usamos Date.now(), los milisegundos actuales, suficiente para una app de un solo usuario.
  • El importe es un número, no un texto. Así se puede sumar.
  • La fecha se guarda como texto "AAAA-MM-DD". Es fácil de ordenar, de guardar en JSON y de mostrar.

Fíjate en que el filtro también es estado: es información que afecta a lo que se ve.

Paso 3: los cálculos

Antes de pintar nada, escribe las funciones que calculan. Son funciones puras: reciben datos y devuelven un resultado, sin tocar el DOM. Se pueden probar en la consola o con Node:

function filtrar(gastos, filtro) {
  return filtro === "todas" ? gastos : gastos.filter((g) => g.categoria === filtro);
}

function calcularTotal(gastos) {
  return gastos.reduce((total, g) => total + g.importe, 0);
}

const euros = new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR" });

calcularTotal(estado.gastos);             // 32.5
euros.format(32.5);                       // "32,50 €"
filtrar(estado.gastos, "comida").length;  // 1

Intl.NumberFormat formatea importes al estilo español (coma decimal y símbolo detrás) sin que tengas que hacerlo a mano.

Paso 4: el render

render lee el estado y deja la página como debe estar. Para cada gasto crea una fila con createElement:

const lista = document.querySelector("#lista");
const vacio = document.querySelector("#vacio");
const total = document.querySelector("#total");

function crearFila(gasto) {
  const li = document.createElement("li");

  const texto = document.createElement("span");
  texto.textContent = `${gasto.concepto} · ${gasto.categoria} · ${gasto.fecha.split("-").reverse().join("/")}`;

  const importe = document.createElement("strong");
  importe.textContent = euros.format(gasto.importe);

  const borrar = document.createElement("button");
  borrar.textContent = "Borrar";
  borrar.dataset.id = gasto.id; // guardamos el id en data-id

  li.append(texto, importe, borrar);
  return li;
}

function render() {
  const visibles = filtrar(estado.gastos, estado.filtro);
  lista.replaceChildren(...visibles.map(crearFila));
  vacio.hidden = visibles.length > 0;
  total.textContent = euros.format(calcularTotal(visibles));
}
  • visibles.map(crearFila) convierte el array de gastos en un array de <li>.
  • replaceChildren(...) sustituye todo el contenido de la lista de una vez: borra lo anterior y pone lo nuevo. Así no se duplican filas.
  • Usamos textContent, nunca innerHTML con lo que escribe el usuario: si alguien escribe <img onerror=...> como concepto, se mostraría como texto y no se ejecutaría.
  • data-id guarda el id en el botón para saber luego qué gasto borrar.

Redibujar toda la lista en cada cambio parece un desperdicio, pero con cientos de elementos es instantáneo. Los frameworks hacen lo mismo, solo que optimizan qué partes cambian de verdad.

Paso 5: acciones y eventos

Las acciones son las únicas funciones que modifican el estado. Todas terminan igual: guardar y renderizar.

function agregarGasto(concepto, importe, categoria) {
  estado.gastos.push({
    id: Date.now(),
    concepto,
    importe,
    categoria,
    fecha: new Date().toISOString().slice(0, 10),
  });
  guardar();
  render();
}

function borrarGasto(id) {
  estado.gastos = estado.gastos.filter((g) => g.id !== id);
  guardar();
  render();
}

borrarGasto no busca el <li> en la página: quita el gasto del array y deja que render haga el resto.

Los eventos leen lo que hace el usuario y llaman a las acciones:

formulario.addEventListener("submit", (evento) => {
  evento.preventDefault();
  const concepto = campoConcepto.value.trim();
  const importe = Number(campoImporte.value);
  if (!concepto || !(importe > 0)) return;
  agregarGasto(concepto, importe, campoCategoria.value);
  formulario.reset();
  campoConcepto.focus();
});

lista.addEventListener("click", (evento) => {
  const boton = evento.target.closest("button[data-id]");
  if (!boton) return;
  borrarGasto(Number(boton.dataset.id));
});

selectorFiltro.addEventListener("change", () => {
  estado.filtro = selectorFiltro.value;
  render();
});

Tres detalles:

  • preventDefault() evita que el formulario recargue la página.
  • campoImporte.value es siempre un texto; hay que convertirlo con Number. Y dataset.id también es texto, por eso Number(boton.dataset.id): sin él, g.id !== id compararía 1 con "1" y no borraría nada.
  • Para los botones de borrar usamos delegación de eventos: un único listener en la <ul> que atiende a todos los botones, incluso los que se crean después. Lo viste en eventos.

Cambiar el filtro no modifica los gastos, así que no hace falta guardar: solo cambia el estado y se renderiza.

Paso 6: guardar en localStorage

Para que los gastos sigan ahí al recargar, se guardan como JSON en localStorage después de cada cambio, y se cargan al arrancar:

const CLAVE = "gastos";

function cargar() {
  try {
    const texto = localStorage.getItem(CLAVE);
    return texto ? JSON.parse(texto) : [];
  } catch {
    return [];
  }
}

function guardar() {
  try {
    localStorage.setItem(CLAVE, JSON.stringify(estado.gastos));
  } catch {
    // Sin almacenamiento: la app sigue funcionando en memoria
  }
}

const estado = {
  gastos: cargar(),
  filtro: "todas",
};

¿Por qué el try...catch? Porque acceder a localStorage puede lanzar un error:

  • El editor “Pruébalo” de DevSchool ejecuta el código en un iframe aislado (con sandbox) y el navegador bloquea el almacenamiento: localStorage lanza un SecurityError solo con tocarlo.
  • Algunos navegadores lo bloquean en modo privado o si el usuario ha desactivado las cookies.
  • setItem falla si se llena el espacio disponible (unos 5 MB).
  • JSON.parse falla si alguien ha dejado un texto corrupto en esa clave.

Sin el try, un error así pararía el script y la app no funcionaría en absoluto. Con él, la app degrada con elegancia: funciona igual, solo que sin recordar los datos. En el editor verás los gastos de ejemplo; copia el código a un archivo .html tuyo y comprobarás que al recargar se mantienen.

Fíjate en que cargar y guardar son las únicas funciones que conocen localStorage. Si mañana quieres guardar en un servidor con fetch, solo cambias esas dos.

Cómo queda organizado el código

El código completo está en el editor de esta página, en este orden:

SecciónContenidoToca el DOM
1. Estadoestado, datos de ejemploNo
2. Guardar y cargarcargar(), guardar()No
3. Cálculosfiltrar(), calcularTotal(), eurosNo
4. RendercrearFila(), render()Sí, solo aquí se pinta
5. AccionesagregarGasto(), borrarGasto()No
6. Eventoslisteners de formulario, lista y filtroSolo leen

Al final, una llamada a render() pinta el estado inicial.

Cuidado con…

  • Olvidar llamar a render() tras cambiar el estado: los datos cambian, pero la página no.
  • Modificar el DOM desde los eventos “para ir más rápido”: rompe la regla y vuelven los problemas de sincronización.
  • Comparar ids de distinto tipo: dataset y value devuelven siempre texto.
  • Usar innerHTML con datos del usuario: abre la puerta a inyectar código.
  • No proteger localStorage ni JSON.parse: un fallo ahí tumba toda la app.
  • Sumar importes guardados como texto: "12.5" + "20" da "12.520".

Ideas para mejorarla

Practica ampliando el proyecto. Cada mejora encaja en la estructura sin romperla:

  • Resumen por categoría con Object.groupBy(estado.gastos, (g) => g.categoria) (lo tienes en Set y Map).
  • Ordenar por fecha o por importe: añade orden al estado y usa toSorted en render.
  • Editar un gasto: un botón que rellene el formulario y una acción editarGasto(id, datos).
  • Filtrar por mes con un <input type="month">.
  • Exportar a CSV: construye el texto con map y join y descárgalo.
  • Confirmar antes de borrar con confirm("¿Seguro?").

Resumen

  • Guarda todos los datos en un objeto estado: es la única fuente de verdad.
  • Escribe una función render() que pinte la página entera desde el estado.
  • Los eventos solo llaman a acciones; las acciones cambian el estado, guardan y llaman a render().
  • Separa los cálculos en funciones puras (filter, reduce): son fáciles de probar.
  • Crea elementos con createElement y textContent, y usa delegación para los botones de cada fila.
  • Guarda en localStorage con JSON y envuelve el acceso en try...catch.

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

proyecto-app.html
Resultado

Pon a prueba lo que has aprendido

[JavaScript] En la arquitectura de estado y render, ¿qué debe hacer un listener de eventos?

[JavaScript] El botón de borrar guarda el id en data-id. ¿Cuántos gastos quedan tras este filtro?
const gastos = [{ id: 1, importe: 12.5 }, { id: 2, importe: 20 }];
const id = "1"; // viene de boton.dataset.id
console.log(gastos.filter((g) => g.id !== id).length);

[JavaScript] ¿Qué muestra este código si los importes se guardaron como texto?
const gastos = [{ importe: "12.5" }, { importe: "20" }];
console.log(gastos.reduce((t, g) => t + g.importe, 0));

[JavaScript] ¿Por qué el proyecto envuelve localStorage.getItem y JSON.parse en un try...catch?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.