Zustand + TanStack Query: A Dupla Poderosa para Gerenciamento de Estado em React
A combinação de Zustand para estado do cliente e TanStack Query para estado do servidor cria uma solução elegante e performática para gerenciamento de estado em React. Este artigo explora como essas bibliotecas se complementam, detalhando a simplicidade do Zustand (Redux sem boilerplate) e a eficiência do TanStack Query para dados de API, cache e sincronização, com exemplos práticos e padrões avançados.

TL;DR: Zustand cuida do estado de cliente (UI, filtros, preferências). TanStack Query cuida do estado de servidor (dados da API, cache, sincronização). Cada um resolve exatamente um problema — e nenhum interfere no outro. Juntos, substituem Redux + lógica de fetch manual com muito menos código e muito mais robustez.

Dois Tipos de Estado, Dois Problemas Diferentes
O ponto de partida desta arquitetura é uma distinção conceitual que parece óbvia depois que você entende, mas que leva muita gente a criar soluções excessivamente complexas: server state e client state são problemas fundamentalmente diferentes.
Server state é qualquer dado que vive fora da sua aplicação — em um banco de dados, em uma API. Ele pode mudar sem que o usuário faça nada (outro usuário editou o mesmo registro, o servidor atualizou um preço). Ele exige busca assíncrona, cache, revalidação e tratamento de erros. Gerenciá-lo com useState + useEffect manual é reinventar a roda — uma roda quadrada.
Client state é o que vive inteiramente no navegador: qual aba está ativa, qual filtro o usuário selecionou, se o modal está aberto, o tema da interface. É síncrono, controlado e não tem staleness. Não precisa de cache — só de reatividade.
A própria documentação do TanStack Query é direta: a biblioteca não substitui gerenciadores de estado de cliente como Zustand. Ela resolve o servidor. O Zustand resolve o cliente. Usar os dois juntos é a arquitetura — não uma escolha entre eles.
Zustand: Estado de Cliente Sem Cerimônia
Zustand ("estado" em alemão) foi criado com uma premissa simples: gerenciamento de estado global não deveria precisar de Providers, reducers, action creators ou connect(). Uma store é um hook. Fim.
A 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)} />}Seletores: o Detalhe que Define a Performance
O ponto crítico que a maioria dos tutoriais ignora: o Zustand compara o retorno do seletor via igualdade estrita (Object.is). Isso significa que três formas de consumir o store têm impactos de performance 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, })))Regra prática: sempre exporte custom hooks que encapsulam os seletores, não a store diretamente. Isso evita que componentes assinem o store inteiro por descuido.
Persist: Preferências que Sobrevivem ao 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 Domínios Separados
Quando o app cresce, divida a store por domínio em slices e combine em um bound store. Middlewares sempre no store combinado, nunca nas slices individuais.
// 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 Feito Direito
O TanStack Query é um gerenciador assíncrono de estado. Toda query é identificada por uma queryKey e executada por uma queryFn. O cache, a revalidação, os retries automáticos e os estados de loading/erro vêm de graça.
Query Keys Hierárquicas e Invalidação em Cascata
A query key é o índice do cache. O padrão recomendado pela comunidade é o query key factory: uma estrutura hierárquica tipada que facilita invalidação em cascata e elimina strings mágicas espalhadas pelo 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) })Cache, Stale-While-Revalidate e Sincronização Automática
O TanStack Query implementa o padrão stale-while-revalidate: quando uma query está stale, o componente recebe os dados do cache imediatamente (sem flash de loading) e uma requisição em background revalida os dados. Os dois parâmetros centrais são:
staleTime: tempo em ms antes dos dados serem considerados obsoletos. Default:0(sempre stale). Defina valores maiores para dados que mudam pouco.gcTime(antigocacheTime): quanto tempo entradas inativas ficam em memória antes do garbage collector removê-las. 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 Carregamento na v5: isPending, isFetching e isLoading
A v5 trouxe uma distinção importante que quebrou código de v4. Entender os três estados evita bugs sutis:
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 com Optimistic Updates e Rollback
O padrão completo de optimistic update exige quatro passos disciplinados. Pular qualquer um cria race conditions ou 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() }) }, })}A Dupla na Prática: Integração Real
A Regra de Ouro
Nunca copie dados do servidor para o Zustand.
Se você está fazendo setQueryData ou setState(resultado_da_api) no Zustand, está reinventando o cache do TanStack Query — mal. O Zustand armazena preferências; o TanStack Query armazena respostas da API. Essas fronteiras não devem se cruzar.
Critérios de Decisão
Use TanStack Query para:
Qualquer dado buscado via API REST ou GraphQL
Listas, detalhes, paginação, infinite scroll
Dados que podem ficar stale porque o servidor pode mudá-los
Mutations (criar, atualizar, deletar) com invalidação automática
Use Zustand para:
Filtros, ordenação e parâmetros de busca que parametrizam queries
Tema, idioma, preferências de layout
Estado de modais, sidebars, toasts globais
Carrinho de compras antes do checkout (antes de persistir no servidor)
Flags de navegação, steps de wizard multi-step
Heurística rápida: se outro sistema pode mudar o dado sem o usuário agir, é server state. Se só o próprio usuário muda, é client state.
Exemplo Integrado: Filtro no Zustand, Query no TanStack
Este é o padrão canônico. Os filtros do Zustand entram na queryKey — quando o usuário muda o filtro, o TanStack Query automaticamente dispara nova requisição ou serve do cache se já foi buscado com aquela combinação.
// 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} /> </> )}O componente não sabe que há duas bibliotecas envolvidas. Ele só consome hooks. Essa é a camada certa de abstração.
Zustand vs Redux: A Comparação Direta
Em 2025–2026, Zustand superou Redux Toolkit em downloads semanais em projetos novos. A migração faz sentido quando você adota TanStack Query para server state — porque o que sobra de state global é pequeno demais para justificar o overhead do Redux.
Bundle: Zustand ~1 kB gzip vs Redux Toolkit ~12–14 kB (sem RTK Query)
Boilerplate: Zustand é hooks diretos vs slices + actions + reducers + Provider no Redux
Curva: Zustand tem um conceito central (
create) vs actions/reducers/selectors/middleware/thunks do ReduxDevTools: ambos integram com Redux DevTools via middleware
devtoolsQuando Redux ainda ganha: times grandes com mandato de arquitetura Flux estrita, bases de código legadas, ou quando RTK Query já está em uso e não compensa migrar
Estrutura de Projeto 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 defaultOptionsConclusão
A arquitetura Zustand + TanStack Query resolve o problema histórico do gerenciamento de estado em React com clareza conceitual: cada biblioteca faz exatamente uma coisa e as faz bem. Você deixa de ter um Redux gigante que tenta cuidar de tudo e passa a ter dois especialistas que não interferem um no outro.
O resultado prático:
Sem lógica de fetch manual (
useEffect+useState+ loading + error + abort)Cache automático com dedução de requests
Revalidação inteligente em background sem código adicional
Estado de UI global com bundle de ~1 kB e API de 10 linhas
TypeScript first-class nas duas bibliotecas
A regra que não muda: nunca copie dados do servidor para o Zustand. Server state mora no TanStack Query. Client state mora no Zustand. Componentes só consomem via hooks. Essa separação é a arquitetura.


