Zustand + TanStack Query: La dupla poderosa para la gestión de estado en React
Descubre por qué Zustand y TanStack Query son la combinación ideal para separar el estado de cliente del servidor y simplificar tu código en React.

TL;DR:Zustand se encarga del estado de cliente (UI, filtros, preferencias). TanStack Query se encarga del estado de servidor (datos de la API, caché, sincronización). Cada uno resuelve exactamente un problema — y ninguno interfiere con el otro. Juntos, reemplazan Redux + lógica de fetch manual con mucho menos código y mucha más robustez.

Dos tipos de estado, dos problemas diferentes
El punto de partida de esta arquitectura es una distinción conceptual que parece obvia después de entenderla, pero que lleva a mucha gente a crear soluciones excesivamente complejas: server state y client state son problemas fundamentalmente diferentes.
Server state es cualquier dato que vive fuera de tu aplicación — en una base de datos, en una API. Puede cambiar sin que el usuario haga nada (otro usuario editó el mismo registro, el servidor actualizó un precio). Exige búsqueda asíncrona, caché, revalidación y tratamiento de errores. Gestionarlo con useState + useEffect manual es reinventar la rueda — una rueda cuadrada.
Client state es lo que vive enteramente en el navegador: qué pestaña está activa, qué filtro seleccionó el usuario, si el modal está abierto, el tema de la interfaz. Es síncrono, controlado y no tiene staleness. No necesita caché — solo reactividad.
La propia documentación de TanStack Query es directa: la biblioteca no reemplaza gestores de estado de cliente como Zustand. Ella resuelve el servidor. Zustand resuelve el cliente. Usar ambos juntos es la arquitectura — no una elección entre ellos.
Zustand: Estado de cliente sin ceremonia
Zustand ("estado" en alemán) fue creado con una premisa simple: la gestión de estado global no debería necesitar Providers, reducers, action creators o connect(). Una store es un hook. Fin.
La API minimalista
import { create } from 'zustand'interface FilterState { selectedCategory: string sortOrder: 'asc' | 'desc' | '' setSelectedCategory: (category: string) => void setSortOrder: (order: 'asc' | 'desc' | '') => void}// A store inteira em um hook — sem Provider, sem boilerplateexport const useFilterStore = create<FilterState>()((set) => ({ selectedCategory: '', sortOrder: '', setSelectedCategory: (selectedCategory) => set({ selectedCategory }), setSortOrder: (sortOrder) => set({ sortOrder }),}))// Consumindo em qualquer componentefunction SortControl() { const sortOrder = useFilterStore((s) => s.sortOrder) const setSortOrder = useFilterStore((s) => s.setSortOrder) return <select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />}Selectores: el detalle que define el rendimiento
El punto crítico que la mayoría de los tutoriales ignora: Zustand compara el retorno del selector mediante igualdad estricta (Object.is). Esto significa que tres formas de consumir la store tienen impactos de rendimiento radicalmente diferentes.
// ❌ Assina o store inteiro — re-renderiza em qualquer mudançaconst state = useFilterStore()// ❌ Cria novo objeto a cada render → re-renderização infinitaconst { category } = useFilterStore((s) => ({ category: s.selectedCategory }))// ✅ Seletor atômico — só re-renderiza quando selectedCategory mudaconst category = useFilterStore((s) => s.selectedCategory)// ✅ Múltiplos valores: use useShallow para comparação shallowimport { useShallow } from 'zustand/react/shallow'const { category, sortOrder } = useFilterStore( useShallow((s) => ({ category: s.selectedCategory, sortOrder: s.sortOrder, })))Regla práctica: siempre exporta custom hooks que encapsulen los selectores, no la store directamente. Esto evita que los componentes se suscriban a toda la store por descuido.
Persist: Preferencias que sobreviven al reload
import { create } from 'zustand'import { persist, createJSONStorage } from 'zustand/middleware'export const useAppStore = create<AppState>()( persist( (set) => ({ theme: 'light' as 'light' | 'dark', sidebarOpen: false, // ← não queremos persistir isso setTheme: (theme) => set({ theme }), toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), }), { name: 'app-preferences', storage: createJSONStorage(() => localStorage), // partialize: persiste só o que faz sentido entre sessões partialize: (s) => ({ theme: s.theme }), } ))// ⚠️ Em Next.js com SSR: proteja o acesso atrás de um flag de hydration// pois localStorage não existe no servidor.Slices Pattern para dominios separados
Cuando la app crece, divide la store por dominio en slices y combínalos en una bound store. Los middlewares siempre van en la store combinada, nunca en los slices individuales.
// store/slices/filterSlice.tsexport const createFilterSlice = (set) => ({ selectedCategory: '', sortOrder: '' as SortOrder, setSelectedCategory: (selectedCategory: string) => set({ selectedCategory }), setSortOrder: (sortOrder: SortOrder) => set({ sortOrder }),})// store/slices/uiSlice.tsexport const createUISlice = (set) => ({ theme: 'light' as Theme, modalOpen: false, setTheme: (theme: Theme) => set({ theme }), openModal: () => set({ modalOpen: true }), closeModal: () => set({ modalOpen: false }),})// store/index.ts — middlewares aqui, nunca nas slicesexport const useBoundStore = create<BoundStore>()( persist( devtools((...a) => ({ ...createFilterSlice(...a), ...createUISlice(...a), })), { name: 'app-store', partialize: (s) => ({ theme: s.theme }) } ))TanStack Query: Estado de servidor hecho correctamente
TanStack Query es un gestor asíncrono de estado. Cada query es identificada por una queryKey y ejecutada por una queryFn. El caché, la revalidación, los retries automáticos y los estados de loading/error vienen de regalo.
Query Keys jerárquicas e invalidación en cascada
La query key es el índice del caché. El estándar recomendado por la comunidad es el query key factory: una estructura jerárquica tipada que facilita la invalidación en cascada y elimina strings mágicas esparcidas por el código.
// queries/productKeys.tsexport const productKeys = { all: ['products'] as const, lists: () => [...productKeys.all, 'list'] as const, list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const, details: () => [...productKeys.all, 'detail'] as const, detail: (id: number) => [...productKeys.details(), id] as const,}// Invalidação hierárquica: invalida tudo que começa com ['products']queryClient.invalidateQueries({ queryKey: productKeys.all })// Invalida só as listas (não os detalhes individuais)queryClient.invalidateQueries({ queryKey: productKeys.lists() })// Invalida só o detalhe de um produto específicoqueryClient.invalidateQueries({ queryKey: productKeys.detail(42) })Caché, Stale-While-Revalidate y sincronización automática
TanStack Query implementa el patrón stale-while-revalidate: cuando una query está stale, el componente recibe los datos del caché inmediatamente (sin flash de loading) y una petición en background revalida los datos. Los dos parámetros centrales son:
staleTime: tiempo en ms antes de que los datos sean considerados obsoletos. Default:0(siempre stale). Define valores mayores para datos que cambian poco.gcTime(antiguocacheTime): cuánto tiempo las entradas inactivas permanecen en memoria antes de que el garbage collector las elimine. Default: 5 minutos.
// Window focus refetching: quando o usuário volta à aba, dados stale são revalidados// Ativo por default — desative globalmente só se tiver motivo realconst queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 2, // dados frescos por 2 minutos refetchOnWindowFocus: true, // padrão — deixe ativo refetchOnReconnect: true, // revalida ao reconectar à internet }, },})// Polling: refetch periódico, condicional via funçãouseQuery({ queryKey: productKeys.detail(jobId), queryFn: () => fetchJobStatus(jobId), refetchInterval: (query) => { // Para o polling quando o job termina if (query.state.data?.status === 'complete') return false return 2_000 // a cada 2s enquanto pendente },})Estados de carga en la v5: isPending, isFetching e isLoading
La v5 trajo una distinción importante que rompió código de la v4. Entender los tres estados evita bugs sutiles:
const { isPending, isFetching, isError, data, error } = useQuery({ queryKey: productKeys.list(filters), queryFn: () => fetchProducts(filters),})// isPending: query ainda não tem dados em cache (primeiro load)// isFetching: queryFn está executando — inclui background refetches// isLoading: atalho para isPending && isFetching (primeiro load ativo)// Padrão correto em v5:if (isPending) return <Spinner /> // sem dados aindaif (isError) return <ErrorAlert>{error.message}</ErrorAlert>// A partir daqui TypeScript sabe que data está definidoreturn ( <> {isFetching && <SubtleRefetchIndicator />} // feedback sutil em background <ProductGrid products={data} /> </>)Mutations con Optimistic Updates y Rollback
El patrón completo de optimistic update exige cuatro pasos disciplinados. Saltar cualquiera crea race conditions o estado inconsistente.
function useUpdateProduct() { const queryClient = useQueryClient() return useMutation({ mutationFn: (product: Partial<Product> & { id: number }) => api.products.update(product), onMutate: async (updatedProduct) => { // 1) Cancelar refetches em voo para evitar race condition await queryClient.cancelQueries({ queryKey: productKeys.lists() }) // 2) Snapshot do estado anterior (para rollback) const previousProducts = queryClient.getQueryData(productKeys.list(filters)) // 3) Atualização otimista no cache queryClient.setQueryData(productKeys.list(filters), (old: Product[]) => old.map((p) => (p.id === updatedProduct.id ? { ...p, ...updatedProduct } : p)) ) return { previousProducts } }, onError: (_err, _vars, context) => { // 4a) Rollback em caso de erro queryClient.setQueryData(productKeys.list(filters), context?.previousProducts) }, onSettled: () => { // 4b) Sempre revalidar com o servidor — sucesso OU erro // Use onSettled, não onSuccess, para garantir resync mesmo após rollback queryClient.invalidateQueries({ queryKey: productKeys.lists() }) }, })}La dupla en la práctica: integración real
La regla de oro
Nunca copies datos del servidor a Zustand.
Si estás haciendo setQueryData o setState(resultado_da_api) en Zustand, estás reinventando el caché de TanStack Query — mal. Zustand almacena preferencias; TanStack Query almacena respuestas de la API. Estas fronteras no deben cruzarse.
Criterios de decisión
Usa TanStack Query para:
Gestiona datos REST o GraphQL, listas, detalles y paginación.
Maneja datos que pueden quedar obsoletos y mutaciones con invalidación.
Almacena filtros, preferencias y estados como modales o sidebars.
Decide rápido con ejemplos claros para cada herramienta
Cualquier dato buscado vía API REST o GraphQL
Listas, detalles, paginación, infinite scroll
Datos que pueden quedar stale porque el servidor puede cambiarlos
Mutations (crear, actualizar, borrar) con invalidación automática
Usa Zustand para:
Filtros, ordenamiento y parámetros de búsqueda que parametrizan queries
Tema, idioma, preferencias de layout
Estado de modales, sidebars, toasts globales
Carrito de compras antes del checkout (antes de persistir en el servidor)
Flags de navegación, steps de wizard multi-step
Heurística rápida: si otro sistema puede cambiar el dato sin que el usuario actúe, es server state. Si solo el propio usuario cambia, es client state.
Ejemplo integrado: Filtro en Zustand, Query en TanStack
Este es el patrón canónico. Los filtros de Zustand entran en la queryKey — cuando el usuario cambia el filtro, TanStack Query automáticamente dispara una nueva petición o sirve desde el caché si ya fue buscado con esa combinación.
// 1) store/useFilterStore.ts — Zustand guarda as preferências do usuárioexport const useFilterStore = create<FilterState>()( persist( (set) => ({ selectedCategory: '', sortOrder: '' as SortOrder, setSelectedCategory: (selectedCategory) => set({ selectedCategory }), setSortOrder: (sortOrder) => set({ sortOrder }), }), { name: 'product-filters' } // preferências persistem entre sessões ))// 2) hooks/useProducts.ts — bridge entre Zustand e TanStack Queryexport function useProducts() { // useShallow evita re-render quando outros campos do store mudam const { selectedCategory, sortOrder } = useFilterStore( useShallow((s) => ({ selectedCategory: s.selectedCategory, sortOrder: s.sortOrder, })) ) return useQuery({ // Os filtros fazem parte da key — cache por combinação de filtros queryKey: productKeys.list({ category: selectedCategory, sort: sortOrder }), queryFn: () => fetchProducts({ category: selectedCategory, sort: sortOrder }), staleTime: 1000 * 60 * 2, // Mantém dados anteriores visíveis enquanto os novos carregam placeholderData: (prev) => prev, })}// 3) ProductList.tsx — componente que consome ambas as libs via hooksfunction ProductList() { const { data, isPending, isFetching, isError, error } = useProducts() const setCategory = useFilterStore((s) => s.setSelectedCategory) if (isPending) return <Spinner /> if (isError) return <ErrorAlert>{error.message}</ErrorAlert> return ( <> <CategorySelector onChange={setCategory} /> {isFetching && <SubtleRefetchIndicator />} <ProductGrid products={data} /> </> )}El componente no sabe que hay dos bibliotecas involucradas. Solo consume hooks. Esa es la capa correcta de abstracción.
Zustand vs Redux: La comparación directa
En 2025–2026, Zustand superó a Redux Toolkit en descargas semanales en proyectos nuevos. La migración tiene sentido cuando adoptas TanStack Query para server state — porque lo que sobra de state global es demasiado pequeño para justificar el overhead de Redux.
Bundle: Zustand ~1 kB gzip vs Redux Toolkit ~12–14 kB (sin RTK Query)
Boilerplate: Zustand son hooks directos vs slices + actions + reducers + Provider en Redux
Curva: Zustand tiene un concepto central (
create) vs actions/reducers/selectors/middleware/thunks de ReduxDevTools: ambos integran con Redux DevTools vía middleware
devtoolsCuándo Redux aún gana: equipos grandes con mandato de arquitectura Flux estricta, bases de código legadas, o cuando RTK Query ya está en uso y no compensa migrar
Estructura de proyecto recomendada
src/ store/ index.ts # useBoundStore com todos os slices slices/ filterSlice.ts # filtros de UI uiSlice.ts # tema, modais, sidebar queries/ productKeys.ts # query key factory useProducts.ts # useQuery + filtros do Zustand useProductMutations.ts # useMutation com optimistic update components/ ProductList.tsx # consome useProducts() FilterPanel.tsx # consome useFilterStore() lib/ queryClient.ts # QueryClient com defaultOptionsConclusión
La arquitectura Zustand + TanStack Query resuelve el problema histórico de la gestión de estado en React con claridad conceptual: cada biblioteca hace exactamente una cosa y la hace bien. Dejas de tener un Redux gigante que intenta encargarse de todo y pasas a tener dos especialistas que no interfieren entre sí.
El resultado práctico:
Sin lógica de fetch manual (
useEffect+useState+ loading + error + abort)Caché automático con deduplicación de requests
Revalidación inteligente en background sin código adicional
Estado de UI global con bundle de ~1 kB y API de 10 líneas
TypeScript first-class en ambas bibliotecas
La regla que no cambia: nunca copies datos del servidor a Zustand. Server state vive en TanStack Query. Client state vive en Zustand. Los componentes solo consumen vía hooks. Esa separación es la arquitectura.


