Tema
Zustand y TanStack Query
En apps reales hay dos tipos de estado muy distintos: el estado del cliente (carrito, tema, si un modal está abierto) y el estado del servidor (productos, pedidos, usuarios que viven en una base de datos). Para cada uno hay una herramienta ideal: Zustand y TanStack Query. Juntas reemplazan a soluciones más pesadas como Redux en la mayoría de los proyectos.
Concepto
| Estado del cliente | Estado del servidor | |
|---|---|---|
| Ejemplos | Carrito, tema, filtros, sidebar abierto | Productos, pedidos, perfil |
| Dueño | Tu app | El servidor (tu copia puede estar desactualizada) |
| Problemas | Compartirlo entre componentes | Caché, carga, errores, reintentos, sincronizar |
| Herramienta | Zustand (o Context) | TanStack Query |
Zustand
Zustand ("estado" en alemán) es una librería de estado global mínima: sin Providers, sin boilerplate y con muy buen rendimiento, porque cada componente sólo se re-renderiza cuando cambia la parte del estado que usa.
bash
npm i zustandCrear un store
jsx
// src/stores/useContadorStore.js
import { create } from 'zustand'
export const useContadorStore = create((set) => ({
// Estado
cuenta: 0,
// Acciones: usan set para actualizar (hace merge superficial)
incrementar: () => set((estado) => ({ cuenta: estado.cuenta + 1 })),
decrementar: () => set((estado) => ({ cuenta: estado.cuenta - 1 })),
reiniciar: () => set({ cuenta: 0 })
}))jsx
// Cualquier componente, sin Provider
import { useContadorStore } from './stores/useContadorStore'
function Pantalla() {
// Selector: el componente sólo se re-renderiza si "cuenta" cambia
const cuenta = useContadorStore((s) => s.cuenta)
return <h1>{cuenta}</h1>
}
function Controles() {
const incrementar = useContadorStore((s) => s.incrementar)
const reiniciar = useContadorStore((s) => s.reiniciar)
return (
<>
<button onClick={incrementar}>+1</button>
<button onClick={reiniciar}>Reiniciar</button>
</>
)
}Caso real: carrito persistente
jsx
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCarritoStore = create(
persist(
(set, get) => ({
items: [], // { id, titulo, precio, imagen, cantidad }
agregar: (producto, cantidad = 1) =>
set((s) => {
const existe = s.items.find((i) => i.id === producto.id)
if (existe) {
return {
items: s.items.map((i) =>
i.id === producto.id ? { ...i, cantidad: i.cantidad + cantidad } : i
)
}
}
return { items: [...s.items, { ...producto, cantidad }] }
}),
cambiarCantidad: (id, cantidad) =>
set((s) => ({
items:
cantidad <= 0
? s.items.filter((i) => i.id !== id)
: s.items.map((i) => (i.id === id ? { ...i, cantidad } : i))
})),
quitar: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
vaciar: () => set({ items: [] }),
// get() lee el estado actual dentro de las acciones
total: () => get().items.reduce((s, i) => s + i.precio * i.cantidad, 0)
}),
{ name: 'carrito' } // clave en localStorage
)
)
// Selectores reutilizables
export const selectTotalPiezas = (s) => s.items.reduce((n, i) => n + i.cantidad, 0)
export const selectTotal = (s) => s.items.reduce((t, i) => t + i.precio * i.cantidad, 0)jsx
import { useCarritoStore, selectTotalPiezas } from '../stores/useCarritoStore'
export default function BotonCarrito() {
const piezas = useCarritoStore(selectTotalPiezas)
return <button aria-label={`Carrito con ${piezas} productos`}>🛒 {piezas}</button>
}jsx
import { useCarritoStore, selectTotal } from '../stores/useCarritoStore'
export default function ResumenCarrito() {
const items = useCarritoStore((s) => s.items)
const total = useCarritoStore(selectTotal)
const cambiarCantidad = useCarritoStore((s) => s.cambiarCantidad)
const vaciar = useCarritoStore((s) => s.vaciar)
if (items.length === 0) return <p>Tu carrito está vacío.</p>
return (
<div>
{items.map((i) => (
<div key={i.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ flex: 1 }}>{i.titulo}</span>
<button onClick={() => cambiarCantidad(i.id, i.cantidad - 1)}>−</button>
{i.cantidad}
<button onClick={() => cambiarCantidad(i.id, i.cantidad + 1)}>+</button>
<span>${(i.precio * i.cantidad).toFixed(2)}</span>
</div>
))}
<strong>Total: ${total.toFixed(2)}</strong>
<button onClick={vaciar}>Vaciar</button>
</div>
)
}Error común
Seleccionar todo el store: const { items, agregar } = useCarritoStore(). Funciona, pero el componente se re-renderiza con cualquier cambio del store. Usa selectores. Si necesitas varios valores en un objeto, usa useShallow:
jsx
import { useShallow } from 'zustand/react/shallow'
const { items, vaciar } = useCarritoStore(useShallow((s) => ({ items: s.items, vaciar: s.vaciar })))Usar el store fuera de React
js
// Por ejemplo, en un interceptor o una función utilitaria
const piezas = useCarritoStore.getState().items.length
useCarritoStore.getState().vaciar()TanStack Query
TanStack Query (antes React Query) maneja el estado del servidor: pide los datos, los guarda en caché, muestra estados de carga y error, reintenta si falla, refresca al volver a la pestaña y sincroniza después de crear, editar o borrar.
Compara: en la lección de APIs necesitaste ~30 líneas con useEffect, AbortController y tres estados. Con TanStack Query:
jsx
const { data, isPending, error } = useQuery({
queryKey: ['productos'],
queryFn: () => fetch('https://dummyjson.com/products').then((r) => r.json())
})Instalación y configuración
bash
npm i @tanstack/react-query @tanstack/react-query-devtoolsjsx
// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // los datos se consideran "frescos" 1 minuto
retry: 1 // reintenta una vez si falla
}
}
})
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)Conceptos clave
- queryKey: identificador único de los datos en la caché (un arreglo). Si incluye variables (
['producto', id]), cada combinación se guarda por separado y la consulta se repite automáticamente al cambiar. - queryFn: función que devuelve una promesa con los datos (debe lanzar error si falla).
- staleTime: cuánto tiempo los datos se consideran frescos. Mientras lo sean, no se vuelven a pedir.
Consultas (useQuery)
js
const BASE = 'https://dummyjson.com'
async function pedir(ruta, opciones) {
const res = await fetch(`${BASE}${ruta}`, opciones)
if (!res.ok) throw new Error(`Error ${res.status}`)
return res.json()
}
export const productosApi = {
listar: ({ pagina = 1, limite = 12, q = '' }) => {
const skip = (pagina - 1) * limite
return q
? pedir(`/products/search?q=${encodeURIComponent(q)}&limit=${limite}&skip=${skip}`)
: pedir(`/products?limit=${limite}&skip=${skip}`)
},
obtener: (id) => pedir(`/products/${id}`),
crear: (datos) =>
pedir('/products/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
}),
eliminar: (id) => pedir(`/products/${id}`, { method: 'DELETE' })
}jsx
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { productosApi } from '../api/productos'
// Centraliza las keys para no escribirlas a mano en cada lugar
export const productosKeys = {
todos: ['productos'],
lista: (filtros) => ['productos', 'lista', filtros],
detalle: (id) => ['productos', 'detalle', id]
}
export function useProductos(filtros) {
return useQuery({
queryKey: productosKeys.lista(filtros),
queryFn: () => productosApi.listar(filtros),
placeholderData: keepPreviousData // mantiene la página anterior mientras carga la siguiente
})
}
export function useProducto(id) {
return useQuery({
queryKey: productosKeys.detalle(id),
queryFn: () => productosApi.obtener(id),
enabled: Boolean(id) // no se ejecuta hasta tener id
})
}
export function useCrearProducto() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: productosApi.crear,
// Al terminar con éxito, marca las listas como obsoletas para que se vuelvan a pedir
onSuccess: () => queryClient.invalidateQueries({ queryKey: productosKeys.todos })
})
}
export function useEliminarProducto() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: productosApi.eliminar,
onSuccess: (_, id) => {
queryClient.removeQueries({ queryKey: productosKeys.detalle(id) })
queryClient.invalidateQueries({ queryKey: productosKeys.todos })
}
})
}jsx
import { useState } from 'react'
import { useProductos, useCrearProducto, useEliminarProducto } from '../hooks/useProductos'
import { useCarritoStore } from '../stores/useCarritoStore'
export default function ListaProductos() {
const [pagina, setPagina] = useState(1)
const { data, isPending, isError, error, isFetching, refetch } = useProductos({ pagina, limite: 8 })
const crear = useCrearProducto()
const eliminar = useEliminarProducto()
const agregar = useCarritoStore((s) => s.agregar)
if (isPending) return <p>Cargando productos…</p>
if (isError) return <p>Error: {error.message} <button onClick={() => refetch()}>Reintentar</button></p>
const paginas = Math.ceil(data.total / 8)
return (
<section>
<button
onClick={() => crear.mutate({ title: 'Producto demo', price: 99 })}
disabled={crear.isPending}
>
{crear.isPending ? 'Creando…' : '+ Crear producto'}
</button>
{crear.isSuccess && <p>✅ Creado (id {crear.data.id})</p>}
{isFetching && <small> Actualizando…</small>}
<ul>
{data.products.map((p) => (
<li key={p.id}>
{p.title} — ${p.price}{' '}
<button onClick={() => agregar({ id: p.id, titulo: p.title, precio: p.price })}>🛒</button>
<button onClick={() => eliminar.mutate(p.id)} disabled={eliminar.isPending}>🗑</button>
</li>
))}
</ul>
<button onClick={() => setPagina((p) => p - 1)} disabled={pagina === 1}>Anterior</button>
<span> {pagina} / {paginas} </span>
<button onClick={() => setPagina((p) => p + 1)} disabled={pagina >= paginas}>Siguiente</button>
</section>
)
}Estados de una query
| Propiedad | Significado |
|---|---|
isPending | Aún no hay datos (primera carga) |
isError / error | La última petición falló |
isSuccess / data | Hay datos |
isFetching | Hay una petición en curso (incluye recargas en segundo plano) |
refetch() | Vuelve a pedir manualmente |
Actualizaciones optimistas
Para que la interfaz responda al instante (por ejemplo, marcar una tarea como hecha) y revertir si el servidor falla:
jsx
const queryClient = useQueryClient()
const toggleTarea = useMutation({
mutationFn: (tarea) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${tarea.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !tarea.completed })
}).then((r) => r.json()),
onMutate: async (tarea) => {
await queryClient.cancelQueries({ queryKey: ['tareas'] })
const anterior = queryClient.getQueryData(['tareas'])
queryClient.setQueryData(['tareas'], (viejas) =>
viejas.map((t) => (t.id === tarea.id ? { ...t, completed: !t.completed } : t))
)
return { anterior } // contexto para revertir
},
onError: (_err, _tarea, contexto) => {
queryClient.setQueryData(['tareas'], contexto.anterior)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tareas'] })
})Buena práctica
- Datos del servidor → TanStack Query. Estado de la UI → Zustand o useState. No copies datos de una query a Zustand: tendrías dos fuentes de verdad.
- Agrupa queries y mutaciones de cada recurso en un archivo de hooks (
useProductos.js) con las keys centralizadas. - Abre las DevTools de TanStack Query para ver la caché en tiempo real.
Error común
Una queryFn que no lanza error con respuestas 4xx/5xx. fetch no falla con esos códigos, así que TanStack Query creerá que todo salió bien. Revisa res.ok y lanza un error.
Ejercicios
- Crea un store de Zustand
useUIStoreconsidebarAbierto,toggleSidebar()ytema(claro/oscuro) persistido en localStorage. - Crea un hook
useUsuario(id)con TanStack Query parahttps://jsonplaceholder.typicode.com/users/:idy un componente con botones "Anterior/Siguiente" para navegar entre usuarios. Observa en las DevTools que al volver a un usuario ya visitado aparece al instante desde la caché.
Ver solución
jsx
// 1. src/stores/useUIStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useUIStore = create(
persist(
(set) => ({
sidebarAbierto: false,
tema: 'claro',
toggleSidebar: () => set((s) => ({ sidebarAbierto: !s.sidebarAbierto })),
setTema: (tema) => set({ tema })
}),
{
name: 'ui',
partialize: (s) => ({ tema: s.tema }) // sólo persiste el tema
}
)
)jsx
// 2.
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
function useUsuario(id) {
return useQuery({
queryKey: ['usuario', id],
queryFn: async () => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
if (!res.ok) throw new Error('No encontrado')
return res.json()
},
staleTime: 5 * 60 * 1000
})
}
export function NavegadorUsuarios() {
const [id, setId] = useState(1)
const { data, isPending, isError } = useUsuario(id)
return (
<div>
<button onClick={() => setId((i) => Math.max(1, i - 1))} disabled={id === 1}>←</button>
<span> Usuario {id} </span>
<button onClick={() => setId((i) => Math.min(10, i + 1))} disabled={id === 10}>→</button>
{isPending && <p>Cargando…</p>}
{isError && <p>Error</p>}
{data && <p><strong>{data.name}</strong> — {data.email}</p>}
</div>
)
}Resumen
- Separa estado del cliente (Zustand/useState) de estado del servidor (TanStack Query).
- Zustand:
create((set, get) => ({...})), sin Provider; usa selectores ypersistpara localStorage. - TanStack Query:
useQueryconqueryKey+queryFnmaneja caché, carga, errores y reintentos. useMutation+invalidateQueriesmantiene los datos sincronizados tras crear, editar o borrar.