Proyecto: una app en JavaScript
En esta lección
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:
- El estado: un objeto de JavaScript con todos los datos de la app. Es la única fuente de verdad.
- 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. UsamosDate.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, nuncainnerHTMLcon lo que escribe el usuario: si alguien escribe<img onerror=...>como concepto, se mostraría como texto y no se ejecutaría. data-idguarda 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.valuees siempre un texto; hay que convertirlo conNumber. Ydataset.idtambién es texto, por esoNumber(boton.dataset.id): sin él,g.id !== idcompararía1con"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:localStoragelanza unSecurityErrorsolo con tocarlo. - Algunos navegadores lo bloquean en modo privado o si el usuario ha desactivado las cookies.
setItemfalla si se llena el espacio disponible (unos 5 MB).JSON.parsefalla 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ón | Contenido | Toca el DOM |
|---|---|---|
| 1. Estado | estado, datos de ejemplo | No |
| 2. Guardar y cargar | cargar(), guardar() | No |
| 3. Cálculos | filtrar(), calcularTotal(), euros | No |
| 4. Render | crearFila(), render() | Sí, solo aquí se pinta |
| 5. Acciones | agregarGasto(), borrarGasto() | No |
| 6. Eventos | listeners de formulario, lista y filtro | Solo 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:
datasetyvaluedevuelven siempre texto. - Usar
innerHTMLcon datos del usuario: abre la puerta a inyectar código. - No proteger
localStorageniJSON.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
ordenal estado y usatoSortedenrender. - 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
mapyjoiny 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
createElementytextContent, y usa delegación para los botones de cada fila. - Guarda en
localStoragecon JSON y envuelve el acceso entry...catch.
Pruébalo tú
Cambia el código y pulsa Ejecutar (o Ctrl + Enter).
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.