Skip to content

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 clienteEstado del servidor
EjemplosCarrito, tema, filtros, sidebar abiertoProductos, pedidos, perfil
DueñoTu appEl servidor (tu copia puede estar desactualizada)
ProblemasCompartirlo entre componentesCaché, carga, errores, reintentos, sincronizar
HerramientaZustand (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 zustand

Crear 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-devtools
jsx
// 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 ​

PropiedadSignificado
isPendingAún no hay datos (primera carga)
isError / errorLa última petición falló
isSuccess / dataHay datos
isFetchingHay 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 ​

  1. Crea un store de Zustand useUIStore con sidebarAbierto, toggleSidebar() y tema (claro/oscuro) persistido en localStorage.
  2. Crea un hook useUsuario(id) con TanStack Query para https://jsonplaceholder.typicode.com/users/:id y 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 y persist para localStorage.
  • TanStack Query: useQuery con queryKey + queryFn maneja caché, carga, errores y reintentos.
  • useMutation + invalidateQueries mantiene los datos sincronizados tras crear, editar o borrar.

Academia Web — aprende, copia y construye.