Proyecto: CRUD completo con htmx 4
Proyecto: CRUD completo con htmx 4
Vamos a construir una aplicación de tareas completa aplicando todo lo anterior. Sin bundler, sin framework de frontend, sin JSON.
Lo que tendrá:
- Listado con búsqueda en vivo y filtros
- Alta con validación en el servidor
- Edición en línea (el
<li>se convierte en formulario) - Eliminación con confirmación y animación
- Contadores que se actualizan solos vía
<hx-partial> - Historial y URLs compartibles
flowchart TD
subgraph Cliente
L[Lista de tareas]
F[Formulario de alta]
B[Barra de búsqueda]
C[Contadores]
end
subgraph Servidor
R1["GET /tareas"]
R2["POST /tareas"]
R3["GET /tareas/:id/editar"]
R4["PUT /tareas/:id"]
R5["PATCH /tareas/:id/estado"]
R6["DELETE /tareas/:id"]
end
B --> R1 --> L
F --> R2 --> L
L --> R3 --> L
L --> R4 --> L
L --> R5 --> L
L --> R6
R2 -.hx-partial.-> C
R5 -.hx-partial.-> C
R6 -.hx-partial.-> C
Estructura
flowchart TD
P[tareas/] --> S[servidor.js]
P --> V[vistas.js]
P --> D[db.js]
P --> PUB[public/]
PUB --> CSS[estilos.css]
PUB --> JS[htmx.min.js]
mkdir tareas && cd tareas
bun init -y
curl -o public/htmx.min.js https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js
La base de datos
// db.js
import { Database } from "bun:sqlite";
export const db = new Database("tareas.sqlite");
db.run(`
CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
hecha INTEGER NOT NULL DEFAULT 0,
creada TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
export const consultas = {
listar: (q, filtro) => {
let sql = "SELECT * FROM tareas WHERE titulo LIKE ?";
const params = [`%${q ?? ""}%`];
if (filtro === "pendientes") sql += " AND hecha = 0";
if (filtro === "hechas") sql += " AND hecha = 1";
sql += " ORDER BY hecha ASC, id DESC";
return db.query(sql).all(...params);
},
obtener: (id) => db.query("SELECT * FROM tareas WHERE id = ?").get(id),
crear: (titulo) =>
db.query("INSERT INTO tareas (titulo) VALUES (?) RETURNING *").get(titulo),
actualizar: (id, titulo) =>
db.query("UPDATE tareas SET titulo = ? WHERE id = ? RETURNING *").get(titulo, id),
alternar: (id) =>
db.query("UPDATE tareas SET hecha = NOT hecha WHERE id = ? RETURNING *").get(id),
eliminar: (id) => db.query("DELETE FROM tareas WHERE id = ?").run(id),
contar: () =>
db.query(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN hecha = 0 THEN 1 ELSE 0 END) AS pendientes
FROM tareas
`).get(),
};
Las vistas
Todo el HTML en un módulo. Fíjate en el escapado: nunca interpoles texto de usuario sin escapar.
// vistas.js
export const escapar = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[c]));
// --- Fragmento: una tarea en modo lectura ---
export const tarea = (t) => `
<li id="tarea-${t.id}" class="tarea ${t.hecha ? "hecha" : ""}">
<input type="checkbox"
${t.hecha ? "checked" : ""}
hx-patch="/tareas/${t.id}/estado"
hx-target="closest li"
hx-swap="outerMorph"
aria-label="Marcar como ${t.hecha ? "pendiente" : "hecha"}">
<span class="titulo">${escapar(t.titulo)}</span>
<button class="icono"
hx-get="/tareas/${t.id}/editar"
hx-target="closest li"
hx-swap="outerHTML">Editar</button>
<button class="icono peligro"
hx-delete="/tareas/${t.id}"
hx-target="closest li"
hx-swap="delete swap:250ms"
hx-confirm="¿Eliminar «${escapar(t.titulo)}»?">Eliminar</button>
</li>`;
// --- Fragmento: una tarea en modo edición ---
export const tareaEditando = (t, error = null) => `
<li id="tarea-${t.id}" class="tarea editando">
<form hx-put="/tareas/${t.id}"
hx-target="closest li"
hx-swap="outerMorph">
<input name="titulo"
value="${escapar(t.titulo)}"
autofocus
hx-on:load="this.setSelectionRange(this.value.length, this.value.length)">
${error ? `<span class="error">${escapar(error)}</span>` : ""}
<button type="submit">Guardar</button>
<button type="button"
hx-get="/tareas/${t.id}"
hx-target="closest li"
hx-swap="outerHTML">Cancelar</button>
</form>
</li>`;
// --- Fragmento: la lista completa ---
export const lista = (tareas) =>
tareas.length === 0
? `<li class="vacio">No hay tareas que coincidan.</li>`
: tareas.map(tarea).join("");
// --- Fragmento: los contadores ---
export const contadores = ({ total, pendientes }) =>
`${pendientes} pendientes de ${total}`;
// --- Página completa ---
export const pagina = (tareas, cuenta, { q = "", filtro = "todas" } = {}) => `
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mis tareas</title>
<meta name="htmx-config" content="transitions:true defaultTimeout:15000">
<link rel="stylesheet" href="/estilos.css">
<script src="/htmx.min.js"></script>
</head>
<body hx-indicator:inherited="#barra-carga">
<div id="barra-carga" class="htmx-indicator"></div>
<main>
<header>
<h1>Mis tareas</h1>
<p id="contadores">${contadores(cuenta)}</p>
</header>
<form id="alta"
hx-post="/tareas"
hx-target="#lista"
hx-swap="afterbegin"
hx-disable="find button"
hx-on:htmx:after:request="if (ctx.response.status < 400) this.reset()">
<input name="titulo" placeholder="Nueva tarea…" autocomplete="off" required>
<button type="submit">Añadir</button>
</form>
<div id="errores" aria-live="polite"></div>
<div class="controles"
hx-get="/tareas"
hx-trigger="input changed delay:300ms from:#buscar, change from:#filtro"
hx-include="this"
hx-target="#lista"
hx-swap="innerMorph"
hx-sync="this:replace"
hx-replace-url="true">
<input id="buscar" type="search" name="q" value="${escapar(q)}" placeholder="Buscar…">
<select id="filtro" name="filtro">
<option value="todas" ${filtro === "todas" ? "selected" : ""}>Todas</option>
<option value="pendientes" ${filtro === "pendientes" ? "selected" : ""}>Pendientes</option>
<option value="hechas" ${filtro === "hechas" ? "selected" : ""}>Hechas</option>
</select>
</div>
<ul id="lista">${lista(tareas)}</ul>
</main>
</body>
</html>`;
Decisiones de diseño en el HTML
| Decisión | Por qué |
|---|---|
hx-swap="outerMorph" al marcar hecha | No se pierde el foco del checkbox |
hx-swap="innerMorph" en la lista al buscar | Sólo cambian las filas que difieren |
hx-sync="this:replace" en los controles | Sólo importa la última búsqueda |
hx-replace-url="true" | La búsqueda no llena el historial |
hx-disable="find button" en el alta | Sin dobles envíos |
hx-target="closest li" | Sin ids en los selectores; el fragmento es reutilizable |
hx-swap="delete swap:250ms" al eliminar | Borra el <li> sin depender del cuerpo, con animación |
El servidor
// servidor.js
import { consultas } from "./db.js";
import { pagina, lista, tarea, tareaEditando, contadores } from "./vistas.js";
const html = (contenido, init = {}) =>
new Response(contenido, {
...init,
headers: { "Content-Type": "text/html; charset=utf-8", ...(init.headers ?? {}) },
});
const esHtmx = (req) => req.headers.get("HX-Request") === "true";
// Partial reutilizable con los contadores
const partialContadores = () =>
`<hx-partial id="contadores">${contadores(consultas.contar())}</hx-partial>`;
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
const { pathname } = url;
const metodo = req.method;
// --- Estáticos ---
if (pathname === "/estilos.css") return new Response(Bun.file("./public/estilos.css"));
if (pathname === "/htmx.min.js") return new Response(Bun.file("./public/htmx.min.js"));
// --- Listado: una ruta, dos respuestas ---
if (pathname === "/tareas" && metodo === "GET") {
const q = url.searchParams.get("q") ?? "";
const filtro = url.searchParams.get("filtro") ?? "todas";
const tareas = consultas.listar(q, filtro);
return esHtmx(req)
? html(lista(tareas))
: html(pagina(tareas, consultas.contar(), { q, filtro }));
}
// --- Alta ---
if (pathname === "/tareas" && metodo === "POST") {
const datos = await req.formData();
const titulo = (datos.get("titulo") ?? "").toString().trim();
if (titulo.length < 3) {
return html(
`<hx-partial hx-target="#errores" hx-swap="innerHTML">
<p class="error" hx-on:load="await timeout('4s'); this.remove()">
El título necesita al menos 3 caracteres.
</p>
</hx-partial>`,
{ status: 422 }
);
}
const nueva = consultas.crear(titulo);
return html(tarea(nueva) + partialContadores(), { status: 201 });
}
const m = pathname.match(/^\/tareas\/(\d+)(\/editar|\/estado)?$/);
if (m) {
const id = Number(m[1]);
const sufijo = m[2];
const actual = consultas.obtener(id);
if (!actual) return html(`<li class="error">La tarea ya no existe</li>`, { status: 404 });
// --- Formulario de edición ---
if (sufijo === "/editar" && metodo === "GET") {
return html(tareaEditando(actual));
}
// --- Cancelar edición ---
if (!sufijo && metodo === "GET") {
return html(tarea(actual));
}
// --- Guardar edición ---
if (!sufijo && metodo === "PUT") {
const datos = await req.formData();
const titulo = (datos.get("titulo") ?? "").toString().trim();
if (titulo.length < 3) {
return html(tareaEditando({ ...actual, titulo }, "Mínimo 3 caracteres"), {
status: 422,
});
}
return html(tarea(consultas.actualizar(id, titulo)));
}
// --- Alternar hecha/pendiente ---
if (sufijo === "/estado" && metodo === "PATCH") {
return html(tarea(consultas.alternar(id)) + partialContadores());
}
// --- Eliminar ---
if (!sufijo && metodo === "DELETE") {
consultas.eliminar(id);
return html(partialContadores());
}
}
// --- Raíz ---
if (pathname === "/") {
return Response.redirect("/tareas", 302);
}
return new Response("No encontrado", { status: 404 });
},
});
console.log("→ http://localhost:3000");
Los patrones aplicados
Una ruta, dos respuestas. GET /tareas devuelve la página completa a un navegador y sólo la
lista a htmx. Eso hace que la URL /tareas?q=café&filtro=pendientes sea compartible y que el botón
“atrás” funcione sin código extra.
Fragmentos autocontenidos. tarea(t) devuelve un <li> que ya trae todos sus atributos htmx.
Da igual si llega en la carga inicial, tras una creación o tras una edición: siempre funciona.
Contadores por partial. Crear, alternar y eliminar devuelven además un <hx-partial> con los
contadores. La lista y el contador se actualizan con una sola petición, sin que el HTML del cliente
sepa nada.
Eliminar usa hx-swap="delete", no outerHTML. La respuesta del DELETE contiene únicamente
un <hx-partial> con los contadores, y recuerda la regla del
capítulo 8: si la respuesta son sólo partials, no hay swap
principal. Con outerHTML el <li> se quedaría en pantalla. Con delete, htmx elimina el target
sin importar el cuerpo, y el partial de los contadores se aplica igual.
Los estilos
/* public/estilos.css */
:root {
--rosa: #ec4899;
--gris: #6b7280;
--borde: #e5e7eb;
}
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
margin: 0;
background: #fafafa;
color: #111;
}
main { max-width: 42rem; margin: 0 auto; padding: 2rem 1rem; }
/* Indicador global */
#barra-carga {
position: fixed; top: 0; left: 0;
height: 3px; width: 100%;
background: linear-gradient(90deg, transparent, var(--rosa), transparent);
animation: deslizar 1s linear infinite;
}
@keyframes deslizar {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
/* htmx: indicadores y fases del swap */
.htmx-indicator { opacity: 0; transition: opacity 150ms; }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { opacity: 1; }
.tarea.htmx-swapping {
opacity: 0;
transform: translateX(-1rem);
transition: all 250ms ease-out;
}
.tarea.htmx-added { opacity: 0; }
.tarea { opacity: 1; transition: opacity 250ms ease-in; }
/* Componentes */
#alta, .controles { display: flex; gap: .5rem; margin-bottom: 1rem; }
#alta input, .controles input { flex: 1; }
input, select, button {
padding: .5rem .75rem;
border: 1px solid var(--borde);
border-radius: .4rem;
font: inherit;
}
button {
background: var(--rosa);
color: white;
border-color: transparent;
cursor: pointer;
}
button:disabled { opacity: .5; cursor: progress; }
button.icono { background: transparent; color: var(--gris); }
button.peligro:hover { color: #dc2626; }
#lista { list-style: none; padding: 0; margin: 0; }
.tarea {
display: flex;
align-items: center;
gap: .5rem;
padding: .75rem;
background: white;
border: 1px solid var(--borde);
border-radius: .5rem;
margin-bottom: .5rem;
}
.tarea .titulo { flex: 1; }
.tarea.hecha .titulo { text-decoration: line-through; color: var(--gris); }
.tarea.editando form { display: flex; gap: .5rem; width: 100%; }
.tarea.editando input { flex: 1; }
.error { color: #dc2626; font-size: .875rem; }
.vacio { text-align: center; color: var(--gris); padding: 2rem; list-style: none; }
/* View Transitions */
@media (prefers-reduced-motion: reduce) {
*, .tarea { transition: none !important; animation: none !important; }
}
Ejecutar
bun run servidor.js
Qué se usó de cada capítulo
| Capítulo | Aplicación en el proyecto |
|---|---|
| 3 — Peticiones | Los cinco verbos, HX-Request para una ruta con dos respuestas |
| 4 — Triggers | input changed delay:300ms, change from:#filtro |
| 5 — Targets | closest li en cada fragmento, sin ids en los selectores |
| 6 — Swaps | outerMorph, innerMorph, delete swap:250ms, afterbegin |
| 7 — Herencia | hx-indicator:inherited en el <body> |
| 8 — Partials | <hx-partial id="contadores"> en tres endpoints |
| 9 — Formularios | hx-include="this" en los controles, validación con 422 |
| 10 — Eventos | hx-on:htmx:after:request para el reset, await timeout() en el error |
| 11 — UX | hx-disable, hx-confirm, barra de carga, clases de swap |
| 12 — Errores | 422 con partial hacia #errores |
| 13 — Historial | hx-replace-url en la búsqueda |
Ejercicios para llevarlo más lejos
- Paginación con scroll infinito: añade
LIMIT/OFFSETy un<li>centinela conhx-trigger="revealed". - Reordenar por arrastre: un
PATCH /tareas/:id/ordenyouterMorphsobre la lista completa. - Multiusuario en vivo:
hx-sse:connect="/eventos"y difunde los<hx-partial>a todas las sesiones cuando alguien cambia algo. - UI optimista: añade
hx-optimistical formulario de alta con un<template>que muestre el texto tecleado. - Etiquetas: una tabla
etiquetasy unhx-includeque sume los checkboxes al filtro.
Resumen
- Toda la aplicación son fragmentos de HTML devueltos por el servidor: nada de JSON.
- Los fragmentos son autocontenidos y usan selectores relativos, así que sirven en cualquier contexto.
- El morphing evita perder foco y scroll en las operaciones más frecuentes.
- Los
<hx-partial>actualizan zonas secundarias sin acoplar el HTML del cliente. - El patrón “una ruta, dos respuestas” mantiene URLs compartibles y el historial funcionando.
- La aplicación completa cabe en tres archivos y no tiene paso de build.
Siguiente: Migración desde htmx 2 →.