React + TypeScript
Semana 9 de 9·8h

Panel admin CRUD conectado a la API

useMutation de TanStack Query, formularios wired a la API real, subida de imágenes desde el navegador y sincronización del estado global.

ReactTanStack QueryZustandFormDataTypeScript
Objetivos de aprendizaje
  • Usar useMutation de TanStack Query para operaciones de escritura tipadas
  • Conectar los formularios de creación y edición al backend real
  • Implementar subida de imágenes desde el navegador con FormData
  • Sincronizar el store de Zustand con los cambios de la API
  • Manejar preview de imagen antes de subir y fallback para imágenes rotas

🎯 Objetivo de la semana: Al terminar el panel admin del CMS funcionará en su totalidad: crear artículos con imagen, editar cualquier campo incluyendo la foto, eliminar con confirmación y ver la lista actualizada al instante sin recargar la página.

🔑 Concepto clave: useMutation + invalidación de caché — la diferencia entre useQuery (leer) y useMutation (escribir) en TanStack Query. Cuando una mutación termina con éxito, queryClient.invalidateQueries() marca el caché de lectura como obsoleto y React vuelve a pedir los datos frescos sin que el usuario note nada.

🛠 Tarea práctica: Conectar NuevoArticulo, EditarArticulo y el botón de eliminar de ArticulosAdmin a la API real. Añadir subida de imagen con preview en ambos formularios.

📋 Entregable: Desde el panel admin puedes crear un artículo con imagen, editarlo cambiando cualquier campo o la foto, y eliminarlo. La tabla se actualiza en tiempo real después de cada operación sin recargar la página.


1. La capa de servicios completa

El articulosService que creaste en S6 solo tenía métodos de lectura. Esta semana lo completas con los métodos de escritura. El patrón es el mismo — una función fetchJSON genérica — pero con method: "POST" o "PUT" y el body serializado como JSON. El método upload es diferente: usa FormData (no JSON) porque el backend espera multipart/form-data:

typescript
// src/services/articulosService.ts — versión completa
import type { Articulo, NuevoArticulo, ActualizarArticulo, ApiResponse } from "@/types";

const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3001";

async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${url}`, {
    headers: { "Content-Type": "application/json" },
    ...init,
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.error ?? `HTTP ${res.status}`);
  }
  return res.json() as Promise<T>;
}

export const articulosService = {
  // Lectura (ya existían desde S6)
  getAll: (p?: { estado?: string; busqueda?: string }) => {
    const qs = p ? new URLSearchParams(p as Record<string, string>).toString() : "";
    return fetchJSON<ApiResponse<Articulo[]>>(`/api/articulos${qs ? `?${qs}` : ""}`);
  },
  getBySlug: (slug: string) =>
    fetchJSON<ApiResponse<Articulo>>(`/api/articulos/${slug}`),

  // Escritura (nuevos esta semana)
  crear: (data: NuevoArticulo) =>
    fetchJSON<ApiResponse<Articulo>>("/api/articulos", {
      method: "POST",
      body: JSON.stringify(data),
    }),

  actualizar: (slug: string, data: ActualizarArticulo) =>
    fetchJSON<ApiResponse<Articulo>>(`/api/articulos/${slug}`, {
      method: "PUT",
      body: JSON.stringify(data),
    }),

  eliminar: (id: number) =>
    fetch(`${BASE}/api/articulos/${id}`, { method: "DELETE" }).then((r) => {
      if (!r.ok && r.status !== 204) throw new Error(`HTTP ${r.status}`);
    }),

  // Upload de imagen — FormData, no JSON
  upload: async (file: File): Promise<string> => {
    const form = new FormData();
    form.append("imagen", file);
    // ⚠️ NO pongas Content-Type manual — el browser añade el boundary automáticamente
    const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
    if (!res.ok) throw new Error("Error al subir la imagen");
    const json = (await res.json()) as ApiResponse<{ filename: string }>;
    return json.data.filename;
  },

  // Construye la URL pública de una imagen
  resolverImagen: (imagen: string | null): string => {
    if (!imagen) return "/assets/img/default-cover.svg";
    if (imagen.startsWith("http") || imagen.startsWith("/")) return imagen;
    return `/uploads/${imagen}`;
  },
};

¿Por qué upload devuelve solo el filename y no la URL completa? Para desacoplar el frontend del backend. Si mañana las imágenes se mueven a S3, solo cambia resolverImagen — el resto del código no sabe dónde están guardadas.


2. useMutation con TypeScript

useQuery es para leer — el hook se suscribe a una clave y React Query gestiona el caché. useMutation es para escribir — no tiene caché propio, se dispara manualmente con mutate(datos) y reporta el estado de la operación con isPending, isError e isSuccess:

typescript
// Los tipos viajan: mutationFn devuelve T, mutate recibe V
function useMutation<TData, TError, TVariables>(options: {
  mutationFn: (variables: TVariables) => Promise<TData>;
  onSuccess?: (data: TData, variables: TVariables) => void;
  onError?: (error: TError, variables: TVariables) => void;
}): {
  mutate: (variables: TVariables) => void;
  mutateAsync: (variables: TVariables) => Promise<TData>;
  isPending: boolean;
  isError: boolean;
  error: TError | null;
};

En la práctica para el CMS:

typescript
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { articulosService } from "@/services/articulosService";
import type { NuevoArticulo } from "@/types";

function useCrearArticulo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (datos: NuevoArticulo) => articulosService.crear(datos),
    onSuccess: () => {
      // Invalida el caché de la lista — al volver a /admin los datos están frescos
      queryClient.invalidateQueries({ queryKey: ["articulos"] });
    },
    onError: (error: Error) => {
      console.error("Error al crear:", error.message);
    },
  });
}

La diferencia entre mutate y mutateAsync:

  • mutate(datos) — dispara la mutación sin bloquear; los callbacks onSuccess/onError se ejecutan cuando termina.
  • mutateAsync(datos) — devuelve una Promise que puedes await directamente, útil cuando necesitas hacer algo después (como navigate).

3. Formulario NuevoArticulo conectado a la API

tsx
// src/pages/admin/NuevoArticulo.tsx
import { useState, type FormEvent, type ChangeEvent } from "react";
import { useNavigate } from "react-router-dom";
import { articulosService } from "@/services/articulosService";
import { useArticulosStore } from "@/store/articulosStore";

export function NuevoArticulo() {
  const navigate = useNavigate();
  const cargarArticulos = useArticulosStore((s) => s.cargarArticulos);

  // Estado del formulario
  const [titulo, setTitulo] = useState("");
  const [extracto, setExtracto] = useState("");
  const [contenido, setContenido] = useState("");
  const [categoriaId, setCategoriaId] = useState<number>(1);
  const [estado, setEstado] = useState<"borrador" | "publicado">("borrador");

  // Estado de imagen
  const [imagenFile, setImagenFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string | null>(null);

  // Estado de la operación
  const [enviando, setEnviando] = useState(false);
  const [errorApi, setErrorApi] = useState<string | null>(null);

  function handleImagenChange(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0] ?? null;
    setImagenFile(file);
    // Preview inmediato con URL local — sin esperar al servidor
    setPreview(file ? URL.createObjectURL(file) : null);
  }

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    setEnviando(true);
    setErrorApi(null);

    try {
      // Paso 1: subir la imagen si hay una seleccionada
      let imagenNombre: string | undefined;
      if (imagenFile) {
        imagenNombre = await articulosService.upload(imagenFile);
      }

      // Paso 2: crear el artículo con el nombre del archivo (no la URL)
      await articulosService.crear({
        titulo,
        extracto,
        contenido,
        imagen: imagenNombre,
        categoria_id: categoriaId,
        estado,
      });

      // Paso 3: refrescar el store ANTES de navegar para evitar datos stale
      await cargarArticulos();
      navigate("/admin");
    } catch (err) {
      setErrorApi(err instanceof Error ? err.message : "Error desconocido");
      setEnviando(false);
    }
  }

  return (
    <main className="admin-page">
      <h1>Nueva publicación</h1>
      {errorApi && <div className="error-banner">{errorApi}</div>}
      <form onSubmit={handleSubmit} className="article-form">
        <div className="form-group">
          <label>Imagen</label>
          {/* Preview: muestra la imagen seleccionada o el placeholder */}
          <img
            src={preview ?? "/assets/img/default-cover.svg"}
            alt="Preview"
            className="image-preview"
            onError={(e) => { e.currentTarget.src = "/assets/img/default-cover.svg"; }}
          />
          <input type="file" accept="image/*" onChange={handleImagenChange} />
        </div>

        <div className="form-group">
          <label>Título *</label>
          <input
            type="text"
            value={titulo}
            onChange={(e) => setTitulo(e.target.value)}
            required
          />
        </div>

        <div className="form-group">
          <label>Extracto</label>
          <textarea
            value={extracto}
            onChange={(e) => setExtracto(e.target.value)}
            rows={3}
          />
        </div>

        <div className="form-group">
          <label>Contenido</label>
          <textarea
            value={contenido}
            onChange={(e) => setContenido(e.target.value)}
            rows={10}
          />
        </div>

        <div className="form-row">
          <div className="form-group">
            <label>Estado</label>
            <select
              value={estado}
              onChange={(e) => setEstado(e.target.value as "borrador" | "publicado")}
            >
              <option value="borrador">Borrador</option>
              <option value="publicado">Publicado</option>
            </select>
          </div>
        </div>

        <div className="form-actions">
          <button type="button" onClick={() => navigate("/admin")}>
            Cancelar
          </button>
          <button type="submit" disabled={enviando}>
            {enviando ? "Guardando..." : "Crear artículo"}
          </button>
        </div>
      </form>
    </main>
  );
}

4. Formulario EditarArticulo con useMutation

El formulario de edición tiene un paso adicional: primero hay que cargar el artículo actual con useQuery para rellenar los campos. useMutation se encarga de enviar los cambios:

tsx
// src/pages/admin/EditarArticulo.tsx
import { useState, type ChangeEvent } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { articulosService } from "@/services/articulosService";
import { useArticulosStore } from "@/store/articulosStore";

export function EditarArticulo() {
  const { slug } = useParams<{ slug: string }>();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const cargarArticulos = useArticulosStore((s) => s.cargarArticulos);

  // Cargar datos actuales del artículo
  const { data, isLoading } = useQuery({
    queryKey: ["articulo", slug],
    queryFn: () => articulosService.getBySlug(slug!),
    enabled: !!slug,
  });
  const articulo = data?.data;

  // Estado local del formulario (se inicializa cuando llegan los datos)
  const [titulo, setTitulo] = useState("");
  const [extracto, setExtracto] = useState("");
  const [contenido, setContenido] = useState("");
  const [estado, setEstado] = useState<"borrador" | "publicado" | "archivado">("borrador");
  const [imagenFile, setImagenFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string | null>(null);
  const [errorApi, setErrorApi] = useState<string | null>(null);

  // Inicializar el formulario cuando llegan los datos
  // (useEffect solo corre cuando articulo cambia de undefined a un objeto)
  const [inicializado, setInicializado] = useState(false);
  if (articulo && !inicializado) {
    setTitulo(articulo.titulo);
    setExtracto(articulo.extracto ?? "");
    setContenido(articulo.contenido ?? "");
    setEstado(articulo.estado);
    setInicializado(true);
  }

  function handleImagenChange(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0] ?? null;
    setImagenFile(file);
    setPreview(file ? URL.createObjectURL(file) : null);
  }

  const mutation = useMutation({
    mutationFn: async () => {
      // Subir nueva imagen solo si el usuario seleccionó una
      let imagenNombre: string | undefined;
      if (imagenFile) {
        imagenNombre = await articulosService.upload(imagenFile);
      }

      return articulosService.actualizar(slug!, {
        titulo,
        extracto,
        contenido,
        estado,
        // Solo incluir imagen si se subió una nueva — el backend preserva la existente
        ...(imagenNombre ? { imagen: imagenNombre } : {}),
      });
    },
    onSuccess: async () => {
      // Invalida el caché del detalle y la lista
      queryClient.invalidateQueries({ queryKey: ["articulo", slug] });
      // Refrescar el store de Zustand ANTES de navegar
      await cargarArticulos();
      navigate("/admin");
    },
    onError: (err: Error) => {
      setErrorApi(err.message);
    },
  });

  if (isLoading) return <div className="loading">Cargando artículo...</div>;
  if (!articulo) return <div className="error">Artículo no encontrado</div>;

  // La imagen a mostrar: preview nuevo > imagen actual del artículo > default
  const imagenMostrada = preview
    ?? articulosService.resolverImagen(articulo.imagen);

  return (
    <main className="admin-page">
      <h1>Editar: {articulo.titulo}</h1>
      {errorApi && <div className="error-banner">{errorApi}</div>}
      <form
        onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }}
        className="article-form"
      >
        <div className="form-group">
          <label>Imagen</label>
          <img
            src={imagenMostrada}
            alt="Preview"
            className="image-preview"
            onError={(e) => { e.currentTarget.src = "/assets/img/default-cover.svg"; }}
          />
          <input type="file" accept="image/*" onChange={handleImagenChange} />
          <small>Selecciona una imagen solo si quieres cambiar la actual</small>
        </div>

        <div className="form-group">
          <label>Título *</label>
          <input
            type="text"
            value={titulo}
            onChange={(e) => setTitulo(e.target.value)}
            required
          />
        </div>

        <div className="form-group">
          <label>Extracto</label>
          <textarea value={extracto} onChange={(e) => setExtracto(e.target.value)} rows={3} />
        </div>

        <div className="form-group">
          <label>Contenido</label>
          <textarea value={contenido} onChange={(e) => setContenido(e.target.value)} rows={10} />
        </div>

        <div className="form-group">
          <label>Estado</label>
          <select value={estado} onChange={(e) => setEstado(e.target.value as typeof estado)}>
            <option value="borrador">Borrador</option>
            <option value="publicado">Publicado</option>
            <option value="archivado">Archivado</option>
          </select>
        </div>

        <div className="form-actions">
          <button type="button" onClick={() => navigate("/admin")}>Cancelar</button>
          <button type="submit" disabled={mutation.isPending}>
            {mutation.isPending ? "Guardando..." : "Guardar cambios"}
          </button>
        </div>
      </form>
    </main>
  );
}

5. Eliminar artículos con confirmación

tsx
// src/pages/admin/ArticulosAdmin.tsx — fragmento del handleEliminar
import { articulosService } from "@/services/articulosService";
import { useArticulosStore } from "@/store/articulosStore";
import Swal from "sweetalert2";

// En el componente:
const { articulos, cargarArticulos, eliminarArticulo } = useArticulosStore();

async function handleEliminar(id: number, titulo: string) {
  const confirmado = await Swal.fire({
    title: "¿Eliminar artículo?",
    text: `"${titulo}" se eliminará permanentemente.`,
    icon: "warning",
    showCancelButton: true,
    confirmButtonColor: "#ef4444",
    confirmButtonText: "Sí, eliminar",
    cancelButtonText: "Cancelar",
  });

  if (!confirmado.isConfirmed) return;

  try {
    await articulosService.eliminar(id);
    // Actualizar el store local sin ir al servidor
    eliminarArticulo(id);
    Swal.fire({ title: "Eliminado", icon: "success", timer: 1500, showConfirmButton: false });
  } catch (err) {
    Swal.fire({ title: "Error", text: "No se pudo eliminar el artículo.", icon: "error" });
  }
}

La diferencia con crear/editar: al eliminar no es necesario llamar a cargarArticulos() porque el store de Zustand ya tiene el método eliminarArticulo(id) que filtra el artículo localmente — es una actualización optimista que no requiere un round-trip al servidor.


6. Sincronización del store de Zustand

El store de Zustand gestiona la lista del panel admin. Para evitar que se muestren datos obsoletos después de crear o editar, hay que resincronizarlo con la API:

typescript
// src/store/articulosStore.ts
import { create } from "zustand";
import { articulosService } from "@/services/articulosService";
import type { Articulo } from "@/types";

interface ArticulosStore {
  articulos: Articulo[];
  cargando: boolean;
  error: string | null;
  cargarArticulos: () => Promise<void>;
  eliminarArticulo: (id: number) => void;
}

export const useArticulosStore = create<ArticulosStore>((set) => ({
  articulos: [],
  cargando: false,
  error: null,

  cargarArticulos: async () => {
    // Limpiar lista al inicio evita que se vean datos stale durante la carga
    set({ cargando: true, articulos: [], error: null });
    try {
      const res = await articulosService.getAll();
      set({ articulos: res.data, cargando: false });
    } catch (err) {
      set({ error: "Error al cargar artículos", cargando: false });
    }
  },

  eliminarArticulo: (id) =>
    set((state) => ({
      articulos: state.articulos.filter((a) => a.id !== id),
    })),
}));

El patrón await cargarArticulos() antes de navigate(): Si navegas antes de que el store se actualice, el componente ArticulosAdmin mostrará la lista antigua mientras cargarArticulos corre en segundo plano — el usuario verá brevemente el artículo que acaba de editar con los datos viejos. await garantiza que la lista está fresca antes de que React renderice la nueva ruta.


7. Imagen por defecto y fallback

Cuando un artículo no tiene imagen o la URL está rota, hay que mostrar un placeholder en lugar de un <img> roto:

tsx
// Componente ArticuloCard — fallback en dos niveles
<img
  src={articulosService.resolverImagen(articulo.imagen)}
  alt={articulo.titulo}
  onError={(e) => {
    // Si la imagen no carga (URL rota), usa el SVG local
    e.currentTarget.onerror = null;  // evitar bucle infinito
    e.currentTarget.src = "/assets/img/default-cover.svg";
  }}
/>

El resolverImagen es el primer nivel: convierte null / "" en el SVG antes de que el <img> lo intente cargar. El onError es el segundo nivel: si la imagen existe en la BD pero el archivo ya no está en disco (fue borrado manualmente), el browser muestra el SVG en lugar del ícono roto.

El SVG default-cover.svg vive en public/assets/img/ — no tiene dependencia de npm, siempre está disponible incluso sin conexión al servidor.


Actividades prácticas

Actividad 1 — Servicio completo (45 min) Añadir crear, actualizar, eliminar y upload al articulosService. Probar cada método desde la consola del navegador con articulosService.crear({...}).

Actividad 2 — useMutation crear (60 min) Implementar NuevoArticulo conectado a la API. Verificar que al guardar el artículo aparece en la tabla del admin sin recargar la página.

Actividad 3 — useMutation editar con imagen (90 min) Implementar EditarArticulo. Probar: (1) editar solo el título sin cambiar imagen, (2) editar cambiando la imagen. Verificar que en ambos casos la imagen correcta aparece en la tabla.

Actividad 4 — Eliminar con Swal (30 min) Conectar el botón de eliminar. Verificar que: (1) el modal de confirmación aparece, (2) cancelar no hace nada, (3) confirmar elimina el artículo y actualiza la tabla.


🛠 Proyecto CMS — Semana 9: Panel admin CRUD completo

Paso 1 — Completar articulosService

Añade los métodos crear, actualizar, eliminar y upload al archivo src/services/articulosService.ts siguiendo el código de la sección 1. Incluye resolverImagen.

Paso 2 — Actualizar el store de Zustand

Modifica cargarArticulos() para que limpie articulos: [] al inicio (evita datos stale). Añade eliminarArticulo(id) si aún no existe.

Paso 3 — NuevoArticulo conectado a la API

Reemplaza el estado local del formulario por la llamada real a articulosService.crear(). Añade la lógica de upload de imagen con preview (URL.createObjectURL). El flujo es: upload imagen (si hay) → crear artículo → await cargarArticulos()navigate("/admin").

Paso 4 — EditarArticulo con useQuery + useMutation

Carga los datos del artículo con useQuery. Inicializa el formulario cuando llegan. Implementa mutation con mutationFn que hace upload (si hay nueva imagen) y luego llama actualizar. En onSuccess: invalidateQueries + await cargarArticulos() + navigate.

Paso 5 — Eliminar con confirmación

Instala sweetalert2 si no está. Conecta el botón de eliminar de ArticulosAdmin. Después de confirmar: await articulosService.eliminar(id)eliminarArticulo(id) (store local).

Paso 6 — Imagen por defecto y fallback

Verifica que ArticuloCard y las <img> de la tabla del admin tienen onError con el SVG de fallback. Crea /public/assets/img/default-cover.svg si no existe.

Paso 7 — Prueba del flujo completo

  1. Login → panel admin → crear artículo con imagen → verificar en tabla
  2. Editar ese artículo → cambiar solo el extracto (imagen debe mantenerse)
  3. Editar → cambiar la imagen → verificar que la nueva foto aparece
  4. Eliminar → verificar que desaparece de la tabla

Para la próxima semana (Semana 10): Con el CRUD completo funcionando, en la Semana 10 añadirás tests con Vitest y React Testing Library para garantizar que los componentes y hooks clave del CMS siguen funcionando cuando hagas cambios en el futuro.


📋 Entregable de la semana

  • articulosService completo: Métodos crear, actualizar, eliminar, upload y resolverImagen implementados y tipados.
  • NuevoArticulo → API: El formulario crea artículos en PostgreSQL. La tabla del admin muestra el nuevo artículo al volver a /admin.
  • Upload de imagen en creación: Seleccionar un archivo muestra el preview. Al guardar, la imagen se sube y el artículo la referencia correctamente.
  • EditarArticulo → API: Editar sin cambiar imagen preserva la foto original. Editar cambiando la imagen sube la nueva y la muestra correctamente.
  • Eliminar con confirmación: El modal de SweetAlert2 aparece antes de eliminar. Cancelar no hace nada. Confirmar elimina de la BD y de la tabla.
  • Fallback de imágenes: Artículos sin imagen muestran default-cover.svg. Imágenes con URL rota también muestran el fallback (probar con onError).
  • Sin datos stale: Después de crear o editar, la tabla del admin muestra los datos actualizados inmediatamente sin recargar la página.