React + TypeScript
Semana 3 de 9·8h

Hooks con TypeScript

useState, useEffect, useRef, useMemo y useCallback completamente tipados con TypeScript.

ReactTypeScript
Objetivos de aprendizaje
  • Usar useState con tipado explícito e inferido correctamente
  • Tipar los efectos secundarios con useEffect y sus dependencias
  • Crear hooks personalizados (custom hooks) con sus propios tipos de retorno
  • Aplicar useReducer con discriminated unions para manejar formularios complejos
  • Optimizar datos derivados con useMemo

🎯 Objetivo de la semana: Al terminar sabrás usar useState, useEffect, useMemo y useReducer completamente tipados, y tendrás los tres custom hooks reales del CMS implementados y conectados a sus consumidores.

🔑 Concepto clave: Hooks como primitivas de composición — cada hook hace una sola cosa bien. usePublicaciones encapsula la carga de datos, useArticulosFiltrados encapsula el cálculo derivado, useFormularioArticulo encapsula el estado del formulario. Los componentes solo los consumen.

🛠 Tarea práctica: Crear los tres custom hooks del CMS:

  • usePublicaciones — carga artículos publicados con useState + useEffect + el servicio mock
  • useArticulosFiltrados — filtra por búsqueda y estado usando useMemo (conecta con el store Zustand en S5)
  • useFormularioArticulo — gestiona el formulario de 4 campos con useReducer y discriminated unions

📋 Entregable: Los 3 hooks tipados compilan sin errores. useFormularioArticulo expone campos, setCampo, reset y esValido. usePublicaciones devuelve { articulos, cargando, error } con cleanup de efecto para evitar actualizaciones en componente desmontado.


1. useState con TypeScript

Hasta ahora los componentes que creaste en la semana 2 eran estáticos: recibían datos como props y los mostraban. El estado es lo que hace que un componente recuerde información entre renders — cada vez que el estado cambia, React vuelve a renderizar el componente con el nuevo valor.

useState en TypeScript funciona con inferencia de tipos en la mayoría de los casos: si le pasas un valor inicial, TypeScript deduce el tipo automáticamente.

tsx
// TypeScript infiere string porque el valor inicial es ""
const [nombre, setNombre] = useState("");

// TypeScript infiere number porque el valor inicial es 0
const [contador, setContador] = useState(0);

// TypeScript infiere boolean[]
const [seleccionados, setSeleccionados] = useState([false, false, false]);

El problema aparece cuando el valor inicial no refleja el tipo real que tendrá el estado. El caso más común es el estado que empieza en null pero después se convierte en un objeto:

tsx
// ❌ TypeScript infiere null — setUsuario solo aceptará null en el futuro
const [usuario, setUsuario] = useState(null);

// ✅ Union type explícito — acepta null al inicio y Usuario después
const [usuario, setUsuario] = useState<Usuario | null>(null);

// ✅ Arrays vacíos también necesitan anotación explícita
const [articulos, setArticulos] = useState<Articulo[]>([]);

Para el estado con objetos complejos, define la interfaz primero y luego úsala en useState. Si el formulario tiene muchos campos, agrupa todo en un único objeto de estado:

tsx
interface FormularioContacto {
  nombre: string;
  email: string;
  mensaje: string;
}

// Un solo useState para todo el formulario
const [form, setForm] = useState<FormularioContacto>({
  nombre: "",
  email: "",
  mensaje: "",
});

// Actualizar un campo sin perder los demás
const handleChange = (campo: keyof FormularioContacto, valor: string) => {
  setForm((prev) => ({ ...prev, [campo]: valor }));
};

La actualización funcional setEstado(prev => ...) es importante cuando el nuevo estado depende del valor anterior. Sin ella, en actualizaciones muy rápidas o asíncronas, podrías leer un estado desactualizado:

tsx
// ❌ Puede leer estado viejo en actualizaciones rápidas
setContador(contador + 1);

// ✅ Siempre lee el valor más reciente
setContador((prev) => prev + 1);

2. useEffect tipado

El ciclo de vida de un componente

Para entender useEffect primero tienes que entender qué le pasa a un componente desde que aparece en pantalla hasta que desaparece.

Un componente React pasa por tres momentos:

  1. Montaje — el componente aparece en el DOM por primera vez. React ejecuta la función del componente, construye el JSX y lo inserta en la página.
  2. Actualización — alguna prop o estado cambia. React vuelve a ejecutar la función y actualiza solo lo que cambió.
  3. Desmontaje — el componente desaparece del DOM. React lo elimina de la página (por ejemplo, navegas a otra ruta o una condición lo oculta).
code
Montaje → Actualización → Actualización → ... → Desmontaje

El problema es que a veces necesitas hacer cosas que no son simplemente "calcular qué mostrar" — necesitas llamar a una API, suscribirte a un evento, o configurar un timer. Esas operaciones tienen que sincronizarse con ese ciclo. Para eso existe useEffect.

¿Cuándo usar useEffect?

La pregunta que tienes que hacerte es: ¿esto es algo que ocurre fuera del render?

  • Cargar datos de una API cuando el componente aparece → useEffect
  • Escuchar el scroll de la ventana → useEffect
  • Suscribirte a un WebSocket → useEffect
  • Cambiar el document.title con el nombre del artículo → useEffect
  • Calcular el total de un carrito → NO (eso es useMemo, ocurre dentro del render)
  • Formatear una fecha → NO (ocurre durante el render, sin efectos externos)

El array de dependencias: los tres comportamientos

El segundo argumento de useEffect controla exactamente cuándo se ejecuta el efecto. Aquí está la confusión más frecuente — hay tres comportamientos distintos:

tsx
// CASO 1 — Sin array: se ejecuta después de CADA render
useEffect(() => {
  console.log("Me ejecuto siempre: en montaje Y en cada actualización");
});

// CASO 2 — Array vacío []: se ejecuta SOLO en el montaje
useEffect(() => {
  console.log("Me ejecuto UNA sola vez, cuando el componente aparece");
}, []);

// CASO 3 — Array con valores: se ejecuta en montaje Y cuando esos valores cambian
useEffect(() => {
  console.log("Me ejecuto cuando 'id' cambia (y también en el montaje)");
}, [id]);

El array vacío [] no significa "no depende de nada para funcionar" — significa "solo quiero que esto ocurra una vez, cuando el componente se monta". Es el patrón correcto para cargar datos iniciales, configurar suscripciones o inicializar librerías externas:

tsx
// ✅ Cargar datos UNA vez al montar — el array vacío es intencional
useEffect(() => {
  fetchArticulosDestacados().then(setArticulos);
}, []); // ← correcto: no depende de ninguna variable del componente

El error más frecuente es usar [] cuando el efecto sí depende de una variable del componente. Si id cambia y el efecto no lo sabe, seguirá usando el id del primer render:

tsx
// ❌ Bug silencioso: si id cambia, el efecto NO se re-ejecuta
// El componente mostrará siempre los datos del id inicial
useEffect(() => {
  fetchUsuario(id);
}, []); // ← id no está en deps

// ✅ Correcto: se re-ejecuta cada vez que id cambia
useEffect(() => {
  fetchUsuario(id);
}, [id]);

ESLint con eslint-plugin-react-hooks detecta automáticamente este error y lo marca como advertencia. Instálalo y no lo ignores.

¿Por qué el efecto no puede ser async?

useEffect espera que su función retorne void o una función de cleanup. Una función async siempre retorna una Promise — y React no sabe qué hacer con una Promise como retorno de un efecto:

tsx
// ❌ React recibe una Promise como retorno — genera advertencia en consola
useEffect(async () => {
  const data = await fetchDatos();
  setDatos(data);
}, []);

// ✅ Declara la función async DENTRO y llámala inmediatamente
useEffect(() => {
  async function cargar() {
    const data = await fetchDatos();
    setDatos(data);
  }
  cargar(); // ← la llamada retorna void, no una Promise
}, []);

La función de cleanup: qué pasa al desmontar

Cuando el componente se desmonta (o antes de que el efecto se vuelva a ejecutar por un cambio de deps), React llama a la función que retorna el efecto. Es el momento para limpiar lo que configuraste: cancelar fetch, desuscribirse de eventos, limpiar timers.

Sin cleanup, puedes tener memory leaks: el efecto intenta actualizar el estado de un componente que ya no existe en el DOM.

tsx
useEffect(() => {
  // SETUP: esto corre al montar (o cuando slug cambia)
  const controller = new AbortController();

  async function fetchArticulo() {
    try {
      const res = await fetch(`/api/articulos/${slug}`, {
        signal: controller.signal,
      });
      const data: Articulo = await res.json();
      setArticulo(data);
    } catch (err) {
      // AbortError no es un fallo real — ocurre cuando el cleanup cancela el fetch
      if (err instanceof Error && err.name !== "AbortError") {
        setError(err.message);
      }
    }
  }

  fetchArticulo();

  // CLEANUP: esto corre al desmontar O antes de re-ejecutar por cambio de slug
  return () => controller.abort();
}, [slug]);

El flujo completo para entenderlo visualmente:

code
Componente monta
  → SETUP: inicia fetch con slug="intro-react"

Usuario navega a otro artículo (slug cambia a "hooks-avanzados")
  → CLEANUP: cancela el fetch anterior (AbortController)
  → SETUP: inicia nuevo fetch con slug="hooks-avanzados"

Componente desmonta (usuario sale de la página)
  → CLEANUP: cancela cualquier fetch pendiente

3. useRef tipado

useRef tiene dos casos de uso completamente distintos que conviene entender por separado, porque el tipado difiere en cada uno.

Caso 1 — Referencia a un elemento del DOM. Cuando necesitas acceder directamente a un nodo HTML (para darle foco, medir su tamaño, reproducir un vídeo...), usas useRef<HTMLTipoDeElemento>(null). El valor inicial siempre es null porque el elemento no existe hasta que el componente se monta:

tsx
// El genérico le dice a TypeScript exactamente qué elemento esperas
const inputRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const divRef = useRef<HTMLDivElement>(null);

function enfocarBuscador() {
  // .current puede ser null si el componente no está montado
  // El optional chaining ?.  evita errores en ese caso
  inputRef.current?.focus();
  inputRef.current?.select(); // selecciona todo el texto
}

return <input ref={inputRef} type="search" />;

Caso 2 — Variable mutable que no dispara re-renders. A diferencia de useState, cambiar .current en un ref no re-renderiza el componente. Esto es ideal para timers, intervalos, el valor anterior de un estado, o cualquier valor que necesites recordar sin afectar la UI:

tsx
// Guardar el ID de un intervalo para poder limpiarlo después
const intervaloRef = useRef<ReturnType<typeof setInterval> | null>(null);

// Nota: ReturnType<typeof setInterval> es más correcto que number
// porque en Node.js devuelve un objeto NodeJS.Timer, no un número

function iniciar() {
  intervaloRef.current = setInterval(() => {
    setSegundos((s) => s + 1);
  }, 1000);
}

function detener() {
  if (intervaloRef.current !== null) {
    clearInterval(intervaloRef.current);
    intervaloRef.current = null;
  }
}

Un caso práctico: guardar el valor anterior de un estado para comparar:

tsx
function usePrevio<T>(valor: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = valor; // se actualiza después de cada render
  });
  return ref.current; // devuelve el valor del render anterior
}

4. useMemo y useCallback tipados

useMemo y useCallback son herramientas de optimización de rendimiento. Antes de usarlos, entiende qué problema resuelven — aplicarlos sin entender el contexto añade complejidad sin beneficio real.

El problema: React re-renderiza un componente cada vez que su estado o props cambian. Normalmente eso es barato y correcto. Pero si dentro del render hay un cálculo costoso (filtrar miles de elementos, ordenar una lista grande), ese cálculo se repite en cada re-render aunque los datos no hayan cambiado.

useMemo memoiza el resultado de un cálculo: solo lo recalcula cuando alguna de sus dependencias cambia:

tsx
// Sin useMemo — este filtro se ejecuta en CADA render del componente
const articulosPublicados = articulos.filter((a) => a.estado === "publicado");

// Con useMemo — solo se recalcula si articulos cambia
const articulosPublicados = useMemo(
  () => articulos.filter((a) => a.estado === "publicado"),
  [articulos] // ← dependencias: solo recalcula si articulos cambia
);

TypeScript infiere el tipo de retorno automáticamente desde la función que le pasas — no necesitas anotar el genérico a menos que haya ambigüedad.

useCallback memoiza una función: devuelve siempre la misma referencia mientras las dependencias no cambien. Esto importa cuando pasas la función como prop a un componente hijo envuelto en React.memo, porque las funciones nuevas en cada render rompen la memoización del hijo:

tsx
// Sin useCallback — nueva función en cada render → hijo se re-renderiza siempre
const handleAgregar = (producto: Producto) => {
  setCarrito((prev) => [...prev, producto]);
};

// Con useCallback — referencia estable → hijo solo re-renderiza si deps cambian
const handleAgregar = useCallback(
  (producto: Producto) => {
    setCarrito((prev) => [...prev, producto]);
  },
  [] // sin deps: la referencia nunca cambia
);

Cuándo NO usarlos: Si el componente es simple y el cálculo es barato, useMemo/useCallback añaden overhead de memorización que puede ser peor que el recálculo. Úsalos cuando notes un problema real de rendimiento, no preventivamente.


5. Custom Hooks con TypeScript

Un custom hook es una función que empieza por use y puede llamar a otros hooks. Es el mecanismo de React para reutilizar lógica con estado entre componentes — el equivalente a las clases utilitarias en OOP, pero para lógica reactiva.

La regla de diseño: si tienes lógica de estado en un componente que podría usarse en otro lugar, sácala a un custom hook. El componente queda limpio y la lógica queda encapsulada y testeable de forma independiente.

El tipado del retorno es lo más importante. Si el hook devuelve un objeto, define una interface para él:

tsx
// Interfaz del retorno — el consumidor del hook sabe exactamente qué recibe
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`Error ${res.status}`);
      const json = await res.json() as T;
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Error desconocido");
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

Si el hook devuelve una tupla (como useState), usa as const para que TypeScript infiera los tipos exactos de cada posición en lugar de un array genérico:

tsx
function useToggle(inicial = false) {
  const [activo, setActivo] = useState(inicial);
  const toggle = useCallback(() => setActivo((v) => !v), []);

  // Sin "as const": TypeScript infiere (boolean | (() => void))[]
  // Con "as const": TypeScript infiere [boolean, () => void] — tupla exacta
  return [activo, toggle] as const;
}

// El consumidor obtiene tipos correctos en cada posición
const [menuAbierto, toggleMenu] = useToggle();

Actividades prácticas

Actividad 1 — useState con formulario complejo (45 min)

Crea un componente FormularioRegistro con campos nombre, email, password y confirmPassword. El estado completo es un único objeto tipado con una interface. Valida en el handleSubmit: email con formato válido (usa una regex), password mínimo 8 caracteres, confirmPassword igual a password. Almacena los errores en Record<keyof FormFields, string> y muéstralos debajo de cada input. Demuestra que setForm(prev => ({ ...prev, [campo]: valor })) con keyof es más mantenible que tener cuatro useState separados.

Actividad 2 — useEffect con fetch y estados de carga (60 min)

Construye un componente ListaUsuarios que cargue datos de https://jsonplaceholder.typicode.com/users. Maneja tres estados: loading (muestra un skeleton o spinner), error (muestra el mensaje con un botón "Reintentar") y data (renderiza la lista). Implementa el AbortController para limpiar el fetch al desmontar. Agrega un select que cambie el endpoint entre /users y /posts — verifica que el AbortController cancela la petición anterior al cambiar.

Actividad 3 — useRef para cronómetro (45 min)

Crea un componente Cronometro con tres botones: Iniciar, Pausar y Reiniciar. El tiempo en segundos va en useState. El setInterval activo va en useRef<ReturnType<typeof setInterval> | null>. Asegúrate de que el cleanup en useEffect limpia el intervalo al desmontar el componente. Agrega también un useRef<HTMLButtonElement> para que el botón "Iniciar" reciba foco automáticamente al montar.

Actividad 4 — Custom hook genérico useFetch<T> (60 min)

Extrae la lógica de fetch de la Actividad 2 en un hook genérico useFetch<T>(url: string) con retorno tipado { data, loading, error, refetch }. Úsalo en tres componentes distintos: ListaUsuarios (tipado con interface Usuario), ListaPosts (tipado con interface Post) y PerfilUsuario (tipado con interface Usuario, usando un id dinámico en la URL). Verifica que TypeScript infiere el tipo de data correctamente en cada componente sin que necesites anotar nada extra en el consumidor.


🛠 Proyecto CMS — Semana 3: Carga asíncrona, progreso de lectura y búsqueda en el admin

Esta semana los hooks dejan de ser ejercicios académicos y se convierten en código real del CMS. Hay tres piezas:

  1. Página pública (HomePage): carga asíncrona de artículos desde el servicio mock.
  2. Formulario del admin (NuevoArticulo): gestión de 4 campos con useReducer y discriminated unions.
  3. Listado del admin (AdminListado): filtrado de artículos con useMemo sobre el store Zustand.

📁 Archivos de esta semana

code
frontend/src/
├── types/
│   └── index.ts                        (semana 1)
├── data/
│   └── mockData.ts                     (semana 2)
├── components/ ...                     (semana 2)
└── hooks/                              ← carpeta NUEVA esta semana
    ├── usePublicaciones.ts             ← NUEVO: carga artículos (useState + useEffect)
    ├── useFormularioArticulo.ts        ← NUEVO: estado del form (useReducer)
    └── useArticulosFiltrados.ts        ← NUEVO: filtrado derivado (useMemo)

Regla de hooks: Siempre van en src/hooks/. Nombre: useNombreDescriptivo.ts — camelCase con prefijo use, extensión .ts (no .tsx a menos que el hook retorne JSX). Nunca crees un hook dentro de un componente o página.


Paso 1 — useEffect para carga asíncrona en HomePage

La HomePage de la semana anterior importaba los mocks directamente. Ahora simula una carga asíncrona: hay un estado cargando y, después de un delay, llegan los artículos. Este patrón es idéntico al que usarás en semana 7 con fetch().

tsx
// src/hooks/usePublicaciones.ts
import { useState, useEffect } from "react";
import type { Articulo } from "@/types";
import { ARTICULOS_MOCK } from "@/data/mockData";

interface EstadoCarga {
  articulos: Articulo[];
  cargando: boolean;
  error: string | null;
}

export function usePublicaciones(): EstadoCarga {
  const [articulos, setArticulos] = useState<Articulo[]>([]);
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Simulamos latencia de red — en Semana 7 esto será fetch('/api/articulos')
    const timer = setTimeout(() => {
      try {
        setArticulos(ARTICULOS_MOCK);
      } catch (e) {
        setError("No se pudieron cargar las publicaciones.");
      } finally {
        setCargando(false);
      }
    }, 600);

    // Cleanup: si el componente desmonta antes de que termine el timer, lo cancelamos
    return () => clearTimeout(timer);
  }, []); // [] = ejecuta solo al montar, nunca más

  return { articulos, cargando, error };
}

Actualiza src/pages/HomePage.tsx:

tsx
// src/pages/HomePage.tsx
import { usePublicaciones } from "@/hooks/usePublicaciones";
import { ArticuloCard } from "@/components/ArticuloCard";

export function HomePage() {
  const { articulos, cargando, error } = usePublicaciones();

  if (cargando) {
    return (
      <main>
        <section className="hero">
          <div className="container">
            <div className="hero__content">
              <h1 className="hero__title">Bienvenido al Blog</h1>
            </div>
          </div>
        </section>
        <div className="container">
          <p className="text-center py-5">Cargando publicaciones...</p>
        </div>
      </main>
    );
  }

  if (error) {
    return <main><p className="text-danger">{error}</p></main>;
  }

  return (
    <main>
      <section className="hero">
        <div className="container">
          <div className="hero__content">
            <h1 className="hero__title">Bienvenido al Blog</h1>
            <p className="hero__subtitle">Últimas noticias de tecnología, gaming y cultura pop</p>
          </div>
        </div>
      </section>

      <section className="posts-section">
        <div className="container">
          <div className="row g-4">
            {articulos.map((a) => (
              <div key={a.id} className="col-md-6 col-lg-6">
                <ArticuloCard articulo={a} />
              </div>
            ))}
          </div>
        </div>
      </section>
    </main>
  );
}

Paso 2 — Formulario del admin: hook useFormularioArticulo

El formulario de nuevo artículo tiene 4 campos relacionados: titulo, extracto, contenido e imagen. Con 4 useState separados tendrías 4 setters distintos y la lógica de reset esparcida. useReducer centraliza todo en un reducer con acciones tipadas como discriminated unions:

ts
// src/hooks/useFormularioArticulo.ts
import { useReducer } from 'react'

interface CamposFormulario {
  titulo: string
  extracto: string
  contenido: string
  imagen: string
}

type AccionFormulario =
  | { type: 'SET_CAMPO'; campo: keyof CamposFormulario; valor: string }
  | { type: 'RESET' }

const estadoInicial: CamposFormulario = {
  titulo: '', extracto: '', contenido: '', imagen: ''
}

function reducirFormulario(state: CamposFormulario, accion: AccionFormulario): CamposFormulario {
  switch (accion.type) {
    case 'SET_CAMPO': return { ...state, [accion.campo]: accion.valor }
    case 'RESET':     return estadoInicial
    default:          return state
  }
}

export function useFormularioArticulo() {
  const [campos, dispatch] = useReducer(reducirFormulario, estadoInicial)

  const setCampo = (campo: keyof CamposFormulario, valor: string) =>
    dispatch({ type: 'SET_CAMPO', campo, valor })

  const reset = () => dispatch({ type: 'RESET' })

  // Validación mínima: título de al menos 3 caracteres
  const esValido = campos.titulo.trim().length >= 3

  return { campos, setCampo, reset, esValido }
}

La ventaja de keyof CamposFormulario en el tipo de campo es que TypeScript rechaza en compilación cualquier campo que no exista en la interfaz. Si renombras un campo, el compilador te indica todos los puntos que hay que actualizar.

Uso en src/pages/admin/NuevoArticulo.tsx:

tsx
import { useFormularioArticulo } from '@/hooks/useFormularioArticulo'

export function NuevoArticulo() {
  const { campos, setCampo, reset, esValido } = useFormularioArticulo()

  const handleSubmit = (e: React.BaseSyntheticEvent) => {
    e.preventDefault()
    if (!esValido) return
    console.log('Enviar:', campos)
    reset()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={campos.titulo}
        onChange={(e) => setCampo('titulo', e.target.value)}
        placeholder="Título del artículo"
      />
      <textarea
        value={campos.extracto}
        onChange={(e) => setCampo('extracto', e.target.value)}
        placeholder="Extracto"
      />
      <button type="submit" disabled={!esValido}>Guardar</button>
      <button type="button" onClick={reset}>Limpiar</button>
    </form>
  )
}

Paso 3 — Filtrado del admin: hook useArticulosFiltrados

El listado del admin necesita filtrar por texto de búsqueda y por estado del artículo. Este cálculo es derivado — depende de otros datos, no es estado propio. useMemo recalcula solo cuando cambian sus dependencias, evitando recorridos innecesarios en cada render.

Este hook leerá del store Zustand que crearás en la Semana 5. Por ahora impleméntalo con datos mock para que compile:

ts
// src/hooks/useArticulosFiltrados.ts
import { useMemo } from 'react'
import type { Articulo, EstadoArticulo } from '@/types'

// En S5 esto leerá del store Zustand: useArticulosStore(state => state.articulos)
export function useArticulosFiltrados(
  articulos: Articulo[],
  busqueda: string,
  filtroEstado: EstadoArticulo | 'todos'
) {
  return useMemo(() => {
    return articulos.filter((a) => {
      const coincideBusqueda = a.titulo
        .toLowerCase()
        .includes(busqueda.toLowerCase())

      const coincideEstado =
        filtroEstado === 'todos' || a.estado === filtroEstado

      return coincideBusqueda && coincideEstado
    })
  }, [articulos, busqueda, filtroEstado])
}

En la Semana 5, cuando crees el store Zustand, useArticulosFiltrados se simplificará: leerá articulos, busqueda y filtroEstado directamente del store. El cálculo con useMemo no cambia.

En la Semana 7, el useEffect de usePublicaciones reemplazará el mock por articulosService.getAll() real hacia Express + PostgreSQL. La arquitectura que estás construyendo ahora anticipa exactamente ese momento.


📥 Descarga el entregable de esta semana

¿No lograste completar la semana o quieres comparar tu código con la solución? Descarga el estado del proyecto al terminar la Semana 3:

⬇️ Descargar entregable Semana 3 (.zip)

Qué incluye: proyecto completo con los tres hooks del CMS en src/hooks/: usePublicaciones, useFormularioArticulo y useArticulosFiltrados, además de todos los componentes y tipos de semanas anteriores.

Nota: El entregable disponible incluye el avance acumulado hasta la Semana 6. Enfoca tu revisión en los archivos de src/hooks/.

Setup después de descomprimir:

bash
cd blog-cms
npm install
npm run dev