Gestión de estado global
useReducer tipado, Zustand para estado compartido y arranque del servidor Express base en monorepo.
Objetivos de aprendizaje
- Distinguir cuándo usar useState, useReducer, Context API y Zustand
- Crear un store Zustand completamente tipado con acciones y estado derivado
- Reestructurar un proyecto Vite en monorepo con carpetas frontend/ y backend/
- Levantar un servidor Express base con rutas /api/v1/articulos devolviendo mock data
- Ejecutar frontend y backend en paralelo con concurrently
🎯 Objetivo de la semana: Al terminar sabrás gestionar estado complejo con
useReducer, configurar un store Zustand para el panel admin y levantar el servidor Express base que alimentará el CMS en las semanas siguientes.🔑 Concepto clave: Elegir la herramienta de estado correcta —
useStatepara estado local simple,useReducerpara lógica compleja en un componente, Zustand para estado compartido entre múltiples componentes sin el boilerplate de Redux.🛠 Tarea práctica: Crear el store Zustand del panel admin con búsqueda + CRUD, y levantar el servidor Express base con las rutas
/api/articulosdevolviendo los datos del mock.📋 Entregable: El panel admin filtra y elimina artículos desde el store Zustand. El servidor Express responde en
http://localhost:3001/api/articulos. Los dos procesos corren en paralelo.
1. useReducer con TypeScript
useState funciona perfectamente para estados simples: un booleano, un string, un número. El problema aparece cuando el estado crece. Imagina el formulario de edición de un artículo: título, extracto, contenido, categoría, estado, imagen, tags. Con useState tienes ocho setters sueltos y actualizar varios a la vez requiere llamadas encadenadas. Cuando la lógica de actualización se comparte entre varios handlers, la empiezas a repetir.
useReducer centraliza toda la lógica de actualización en una sola función — el reducer. El componente despacha acciones con nombre descriptivo; el reducer decide cómo cambia el estado.
Las discriminated unions de TypeScript hacen que los reducers sean completamente type-safe: el compilador infiere el tipo del payload según el type de la acción:
// Las acciones como discriminated union — TypeScript infiere el payload de cada tipo
type CarritoAction =
| { type: "AGREGAR"; payload: ItemCarrito }
| { type: "ELIMINAR"; payload: number } // payload es number
| { type: "LIMPIAR" }; // sin payload
function carritoReducer(state: CarritoState, action: CarritoAction): CarritoState {
switch (action.type) {
case "AGREGAR":
return { ...state, items: [...state.items, action.payload] };
case "ELIMINAR":
// TypeScript sabe que action.payload es number en este case
return { ...state, items: state.items.filter((i) => i.id !== action.payload) };
case "LIMPIAR":
return { items: [], total: 0 };
default: {
// El tipo never verifica que todos los casos están cubiertos
const _exhaustivo: never = action;
return _exhaustivo;
}
}
}El default: never es el truco para verificar exhaustividad: si añades una acción nueva al union type sin actualizar el reducer, TypeScript lanza un error de compilación en el default. Es tu red de seguridad cuando el proyecto crece.
2. Redux Toolkit: lectura de referencia (no se usa en el proyecto)
⚠️ Nota pedagógica: Redux Toolkit no se usa en el CMS Blog del programa. El proyecto elige Zustand por su menor boilerplate para equipos pequeños. Esta sección existe como referencia de industria — en equipos grandes y apps enterprise, RTK es el estándar. Léela si quieres entender por qué existe Redux, pero no la apliques en el proyecto.
// slice + thunk: estado, reducers y petición asíncrona en un lugar
const articulosSlice = createSlice({
name: "articulos",
initialState: { items: [] as ArticuloListado[], cargando: false },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchArticulos.pending, (state) => { state.cargando = true; })
.addCase(fetchArticulos.fulfilled, (state, { payload }) => {
state.cargando = false;
state.items = payload; // payload tipado como ArticuloListado[]
});
},
});
// Hooks tipados — definidos una vez, usados en toda la app sin repetir tipos
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();Para el CMS del programa usarás Zustand — menor boilerplate para un proyecto de equipo pequeño. Redux Toolkit es la elección cuando la app escala a decenas de slices y múltiples desarrolladores.
4. Zustand como alternativa ligera
Si Redux es una fábrica con protocolos estrictos, Zustand es un taller con reglas simples. Sin boilerplate de actions/reducers, sin providers que envuelvan la app, sin hooks tipados manualmente. El store es un objeto con estado y acciones en el mismo lugar:
// Todo en un objeto — estado + acciones, sin boilerplate
const useCarritoStore = create<CarritoStore>((set, get) => ({
items: [],
total: 0,
agregar: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.precio,
})),
eliminar: (id) => set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
limpiar: () => set({ items: [], total: 0 }),
}));
// El selector granular evita re-renders innecesarios
function ContadorCarrito() {
// Solo re-renderiza cuando total cambia, no cuando cambia items
const total = useCarritoStore((state) => state.total);
const limpiar = useCarritoStore((state) => state.limpiar);
return <button onClick={limpiar}>Vaciar ({total})</button>;
}¿Cuándo usar qué? Esta es la guía práctica:
| Escenario | Solución recomendada |
|---|---|
| Estado local del componente (toggle, input) | useState |
| Estado local complejo con múltiples acciones | useReducer |
| Estado global liviano (tema, auth) | Context + useReducer |
| Estado global con múltiples consumidores | Zustand |
| App enterprise con equipos grandes y debug avanzado | Redux Toolkit |
Actividades prácticas
Actividad 1 — Carrito con useReducer (60 min)
Implementar un carrito de compras completo con useReducer. Acciones: agregar, eliminar, cambiar cantidad, limpiar. Calcular total con useMemo.
Actividad 2 — Zustand: store con filtros (60 min)
Crear un store Zustand para una lista de tareas con busqueda y filtroEstado como estado. Implementar setBusqueda, setFiltroEstado, toggleEstado y eliminar. Usar un componente separado que solo lea busqueda del store y verifique que no re-renderiza cuando cambia otro campo.
Actividad 3 — Zustand: refactor (45 min) Migrar el carrito de la Actividad 1 a Zustand. Comparar la cantidad de boilerplate y la experiencia de desarrollo.
🛠 Proyecto CMS — Semana 5: Estado global del panel admin
El panel admin del CMS maneja una tabla de artículos con búsqueda, filtros por estado y acciones CRUD. Esto tiene demasiado estado interrelacionado para useState sueltos — es el caso de uso perfecto para useReducer o Zustand.
📁 Archivos de esta semana
cms-blog/ ← raíz del monorepo (se crea ahora)
├── package.json ← NUEVO: script "dev" con concurrently
├── frontend/ ← RENOMBRADO desde cms-blog/
│ └── src/
│ ├── store/ ← carpeta NUEVA esta semana
│ │ └── articulosStore.ts ← NUEVO: store Zustand del panel admin
│ ├── hooks/
│ │ └── useArticulosFiltrados.ts ← ACTUALIZADO: ahora lee de Zustand
│ ├── context/ ... (semana 4)
│ ├── hooks/ ... (semana 3)
│ └── types/ ... (semana 1)
└── backend/ ← carpeta NUEVA esta semana
├── package.json ← NUEVO: dependencias Express
├── tsconfig.json ← NUEVO
├── .env ← NUEVO: PORT, FRONTEND_URL (en .gitignore)
└── src/
├── app.ts ← NUEVO: servidor Express con CORS
└── routes/
└── articulos.ts ← NUEVO: GET /api/v1/articulos (mock)Regla del store:
src/store/— solo stores Zustand. Nombre:nombreStore.tscon sufijoStore. El store no llama afetchdirectamente en esta semana — eso llega en S6 conarticulosService.⚠️ Crítico sobre el backend: Levanta siempre el backend desde
cms-blog/backend/o con--prefix backenddesde la raíz. Si lo levantas desde la raíz sin prefix,dotenvno encuentrabackend/.envy Prisma no conecta.
Paso 1 — Store de artículos con Zustand
Instala Zustand y crea el store del panel admin:
npm install zustand// src/store/articulosStore.ts
import { create } from 'zustand'
import type { ArticuloListado, EstadoArticulo } from '@/types'
import { articulosService } from '@/services/articulosService'
interface ArticulosState {
// ── Datos ──────────────────────────────────────────
articulos: ArticuloListado[]
busqueda: string
filtroEstado: EstadoArticulo | 'todos'
cargando: boolean
error: string | null
// ── Acciones ───────────────────────────────────────
cargarArticulos: () => Promise<void>
setBusqueda: (texto: string) => void
setFiltroEstado: (estado: EstadoArticulo | 'todos') => void
eliminarArticulo: (id: number) => void
toggleEstado: (id: number) => void
}
export const useArticulosStore = create<ArticulosState>((set, get) => ({
articulos: [],
busqueda: '',
filtroEstado: 'todos',
cargando: false,
error: null,
// En S5 carga desde mock. En S7 cargará desde la API real.
cargarArticulos: async () => {
set({ cargando: true, error: null })
try {
const res = await articulosService.getAll(undefined, true)
set({ articulos: res.data, cargando: false })
} catch {
set({ error: 'No se pudieron cargar los artículos.', cargando: false })
}
},
setBusqueda: (busqueda) => set({ busqueda }),
setFiltroEstado: (filtroEstado) => set({ filtroEstado }),
eliminarArticulo: (id) =>
set((state) => ({
articulos: state.articulos.filter((a) => a.id !== id)
})),
toggleEstado: async (id) => {
const articulo = get().articulos.find((a) => a.id === id)
if (!articulo) return
const nuevoEstado = articulo.estado === 'publicado' ? 'borrador' : 'publicado'
// Actualización optimista — cambia la UI antes de esperar al servidor
set((state) => ({
articulos: state.articulos.map((a) =>
a.id === id ? { ...a, estado: nuevoEstado } : a
)
}))
// En S9 se añadirá la llamada real a articulosService.actualizar()
// y el rollback si falla
},
}))Paso 2 — Conectar useArticulosFiltrados al store
En la Semana 3 creaste useArticulosFiltrados recibiendo los datos como parámetros. Ahora que existe el store, simplifica el hook para que lea directamente de Zustand:
// src/hooks/useArticulosFiltrados.ts — versión final (Semana 5)
import { useMemo } from 'react'
import { useArticulosStore } from '@/store/articulosStore'
export function useArticulosFiltrados() {
const articulos = useArticulosStore((state) => state.articulos)
const busqueda = useArticulosStore((state) => state.busqueda)
const filtroEstado = useArticulosStore((state) => state.filtroEstado)
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])
}Los selectores granulares (state => state.busqueda) evitan re-renders innecesarios: useArticulosFiltrados solo se actualiza cuando cambia articulos, busqueda o filtroEstado, no cuando cambia cualquier otro campo del store.
### Paso 3 — Tabla del panel admin
Crea `src/pages/admin/ArticulosAdmin.tsx` replicando la tabla de `admin_listado.html`:
```tsx
// src/pages/admin/ArticulosAdmin.tsx
import { useArticulosStore } from "@/store/articulosStore";
import { useArticulosFiltrados } from "@/hooks/useArticulosFiltrados";
export function ArticulosAdmin() {
const { filtros, setBusqueda, setEstado, eliminarArticulo, toggleEstado } =
useArticulosStore();
const articulosFiltrados = useArticulosFiltrados();
return (
<div className="admin-panel">
<header className="admin-header">
<div className="admin-header__info">
<h1><i className="bi bi-grid-1x2" /> Publicaciones</h1>
<p>Gestiona el contenido de tu blog.</p>
</div>
<a href="/admin/nuevo" className="btn btn--success">
<i className="bi bi-plus-circle" /> Nueva publicación
</a>
</header>
{/* Filtros */}
<div className="filters">
<input
type="search"
value={filtros.busqueda}
onChange={(e) => setBusqueda(e.target.value)}
placeholder="Buscar artículos..."
/>
<select
value={filtros.estado}
onChange={(e) => setEstado(e.target.value as typeof filtros.estado)}
>
<option value="todos">Todos</option>
<option value="publicado">Publicados</option>
<option value="borrador">Borradores</option>
</select>
</div>
{/* Tabla */}
<table className="admin-table">
<thead>
<tr>
<th>Título</th><th>Categoría</th><th>Estado</th><th>Fecha</th><th>Acciones</th>
</tr>
</thead>
<tbody>
{articulosFiltrados.map((a) => (
<tr key={a.id}>
<td>{a.titulo}</td>
<td>{a.categoria.nombre}</td>
<td>
<button onClick={() => toggleEstado(a.id)}>
{a.estado === "publicado" ? "🟢 Publicado" : "⚪ Borrador"}
</button>
</td>
<td>{a.fechaPublicacion}</td>
<td>
<a href={`/admin/editar/${a.id}`}>Editar</a>
<button onClick={() => eliminarArticulo(a.id)}>Eliminar</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Paso 4 — Arquitectura del proyecto: monorepo react-cms/
Hasta ahora el frontend vive en cms-blog/ — una carpeta independiente. A partir de esta semana el proyecto tiene dos partes que deben correr en paralelo: el frontend React y el servidor Express. El momento de añadir el backend es también el momento de organizar el proyecto de forma definitiva.
La estructura final se llama monorepo: un único repositorio con dos subproyectos dentro. Así es como quedará el proyecto al terminar el programa:
react-cms/ ← carpeta raíz del proyecto completo
├── frontend/ ← la app Vite + React (antes llamada cms-blog/)
│ ├── src/
│ ├── public/
│ ├── package.json ← dependencias del frontend
│ └── vite.config.ts
├── backend/ ← el servidor Express + PostgreSQL
│ ├── src/
│ ├── package.json ← dependencias del backend
│ └── tsconfig.json
├── package.json ← raíz: scripts para arrancar ambos a la vez
├── package-lock.json
└── .gitignore¿Por qué esta estructura y no dos carpetas separadas?
- Un solo
git init— todo el proyecto vive en un repositorio. - Un solo
.gitignoreraíz que protege ambosnode_modules/y los.env. - Un único comando desde la raíz (
npm run dev) arranca el frontend y el backend en paralelo. - En producción o entrevistas, clonas el repo y con
npm install && npm run devtienes todo funcionando.
Paso 4a — Renombrar cms-blog a frontend
Antes de crear el backend, renombra la carpeta del frontend para que coincida con la arquitectura final:
# Ejecuta esto desde el nivel donde está cms-blog/
mv cms-blog react-cms
cd react-cms
mv . frontend # esto no funciona directo — usa el explorador de VS Code
# Forma más segura: arrastrar la carpeta cms-blog dentro de react-cms/frontend en VS CodeLa forma más sencilla en VS Code: crea la carpeta react-cms/ a mano, luego arrastra cms-blog/ dentro y renómbrala a frontend. El proyecto Vite no necesita saber el nombre de la carpeta contenedora — npm run dev dentro de frontend/ sigue funcionando igual.
Paso 4b — Crear el backend dentro de react-cms/
# Desde react-cms/
mkdir backend && cd backend
npm init -y
npm install express cors dotenv
npm install -D typescript @types/express @types/node ts-node-dev
npx tsc --initEstructura del backend:
backend/
├── src/
│ ├── data/
│ │ └── mockData.ts ← copia los tipos y ARTICULOS_MOCK del frontend
│ ├── routes/
│ │ └── articulos.ts ← GET /api/articulos, GET /api/articulos/:slug
│ └── app.ts
├── .env
└── package.json// backend/src/app.ts
import express from "express";
import cors from "cors";
import "dotenv/config";
import articulosRouter from "./routes/articulos";
const app = express();
const PORT = process.env.PORT ?? 3001;
// Solo acepta peticiones del frontend React en desarrollo
app.use(cors({ origin: process.env.FRONTEND_URL ?? "http://localhost:5173" }));
app.use(express.json());
app.use("/api/articulos", articulosRouter);
app.listen(PORT, () =>
console.log(`✅ Servidor CMS en http://localhost:${PORT}`)
);// backend/src/routes/articulos.ts
// Semana 5: devuelve mock en memoria. Semana 7: este archivo consulta PostgreSQL
import { Router } from "express";
import { ARTICULOS_MOCK } from "../data/mockData";
const router = Router();
router.get("/", (req, res) => {
const { estado, busqueda } = req.query;
let resultados = [...ARTICULOS_MOCK];
if (estado && estado !== "todos")
resultados = resultados.filter((a) => a.estado === estado);
if (busqueda && typeof busqueda === "string")
resultados = resultados.filter((a) =>
a.titulo.toLowerCase().includes(busqueda.toLowerCase())
);
res.json({ data: resultados, status: 200 });
});
router.get("/:slug", (req, res) => {
const articulo = ARTICULOS_MOCK.find((a) => a.slug === req.params.slug);
if (!articulo) return res.status(404).json({ error: "No encontrado" });
res.json({ data: articulo, status: 200 });
});
export default router;// backend/package.json — scripts del servidor
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/app.ts",
"build": "tsc"
}
}Paso 4c — Package.json raíz con concurrently
El package.json en la raíz de react-cms/ no instala dependencias del proyecto — solo tiene el script que arranca ambos procesos en paralelo usando concurrently:
# Desde react-cms/ (la raíz del monorepo)
npm init -y
npm install -D concurrently// react-cms/package.json
{
"name": "react-cms",
"version": "1.0.0",
"private": true,
"description": "CMS Blog — Frontend (Vite + React) + Backend (Express + PostgreSQL)",
"scripts": {
"dev": "concurrently -n frontend,backend -c cyan,yellow \"npm run dev --prefix frontend\" \"npm run dev --prefix backend\"",
"build": "npm run build --prefix frontend",
"test": "npm test --prefix frontend",
"install:all": "npm install --prefix frontend && npm install --prefix backend"
},
"devDependencies": {
"concurrently": "^9.0.0"
}
}El flag -n frontend,backend etiqueta cada proceso con su nombre en la terminal. -c cyan,yellow les da un color diferente para distinguirlos visualmente.
Paso 4d — Variables de entorno
Crea el .env dentro de frontend/:
# frontend/.env
VITE_API_URL=http://localhost:3001Crea el .env dentro de backend/:
# backend/.env
PORT=3001
FRONTEND_URL=http://localhost:5173Y el .gitignore raíz que protege ambos:
# react-cms/.gitignore
node_modules/
frontend/node_modules/
backend/node_modules/
frontend/.env
backend/.env
frontend/dist/Arrancar el proyecto completo
Desde la raíz react-cms/ un único comando:
npm run devVerás dos streams en la misma terminal:
[frontend] VITE v5.x.x ready in 300ms
[frontend] ➜ Local: http://localhost:5173/
[backend] ✅ Servidor CMS en http://localhost:3001Para la próxima semana: En la Semana 6 configurarás React Router con URLs reales y conectarás el frontend al servidor Express — los datos del blog vendrán de la API, no del mock. El comando
npm run devdesde la raíz sigue siendo el mismo.
📋 Entregable de la semana
Para considerar completada la Semana 5, debes demostrar:
- Store Zustand activo: El store
useArticulosStoreestá configurado. Los componentes leen del store, no de imports directos demockData. - Búsqueda funcional: El input del panel admin filtra la tabla en tiempo real — escribir "Angewomon" muestra solo ese artículo.
- Eliminar sin recarga: Click en eliminar remueve el artículo de la tabla instantáneamente.
- Toggle publicado/borrador: Click en el estado de un artículo alterna entre publicado y borrador de forma inmediata.
- Servidor Express arranca:
npm run devdesde la raízreact-cms/inicia el frontend y el backend en paralelo.GET http://localhost:3001/api/articulosresponde con el array de artículos en JSON. - Estructura monorepo creada: El proyecto tiene la carpeta raíz
react-cms/confrontend/(el proyecto Vite) ybackend/(el servidor Express) dentro. Elpackage.jsonraíz tiene el scriptdevconconcurrently. - Sin errores TypeScript:
npm run builddel frontend compila sin errores.
📥 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 5:
⬇️ Descargar entregable Semana 5 (.zip)
Qué incluye: el proyecto completo en estructura monorepo — frontend/ con Zustand store, hooks, context, componentes y tipos; backend/ con servidor Express, CORS y rutas /api/v1/articulos devolviendo mock data; package.json raíz con concurrently.
Setup después de descomprimir:
cd blog-cms
npm install # instala concurrently en la raíz
cd frontend && npm install && cd ..
cd backend && pnpm install && cd ..
npm run dev # arranca frontend (5173) + backend (3001) en paralelo