Proyecto: CRUD completo con htmx 4

Por: Artiko
htmxhtmx4proyectocrudbunsqlite

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) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
  }[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ónPor qué
hx-swap="outerMorph" al marcar hechaNo se pierde el foco del checkbox
hx-swap="innerMorph" en la lista al buscarSólo cambian las filas que difieren
hx-sync="this:replace" en los controlesSólo importa la última búsqueda
hx-replace-url="true"La búsqueda no llena el historial
hx-disable="find button" en el altaSin dobles envíos
hx-target="closest li"Sin ids en los selectores; el fragmento es reutilizable
hx-swap="delete swap:250ms" al eliminarBorra 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ítuloAplicación en el proyecto
3 — PeticionesLos cinco verbos, HX-Request para una ruta con dos respuestas
4 — Triggersinput changed delay:300ms, change from:#filtro
5 — Targetsclosest li en cada fragmento, sin ids en los selectores
6 — SwapsouterMorph, innerMorph, delete swap:250ms, afterbegin
7 — Herenciahx-indicator:inherited en el <body>
8 — Partials<hx-partial id="contadores"> en tres endpoints
9 — Formularioshx-include="this" en los controles, validación con 422
10 — Eventoshx-on:htmx:after:request para el reset, await timeout() en el error
11 — UXhx-disable, hx-confirm, barra de carga, clases de swap
12 — Errores422 con partial hacia #errores
13 — Historialhx-replace-url en la búsqueda

Ejercicios para llevarlo más lejos

  1. Paginación con scroll infinito: añade LIMIT/OFFSET y un <li> centinela con hx-trigger="revealed".
  2. Reordenar por arrastre: un PATCH /tareas/:id/orden y outerMorph sobre la lista completa.
  3. Multiusuario en vivo: hx-sse:connect="/eventos" y difunde los <hx-partial> a todas las sesiones cuando alguien cambia algo.
  4. UI optimista: añade hx-optimistic al formulario de alta con un <template> que muestre el texto tecleado.
  5. Etiquetas: una tabla etiquetas y un hx-include que 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 →.