Frontend

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.

Zustand + TanStack Query: A Dupla Poderosa para Gerenciamento de Estado em React

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

TYPESCRIPT
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.

TYPESCRIPT
// ❌ 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

TYPESCRIPT
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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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 (antigo cacheTime): quanto tempo entradas inativas ficam em memória antes do garbage collector removê-las. Default: 5 minutos.

TYPESCRIPT
// 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:

TYPESCRIPT
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.

TYPESCRIPT
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.

TYPESCRIPT
// 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 Redux

  • DevTools: ambos integram com Redux DevTools via middleware devtools

  • Quando 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

TEXT
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 defaultOptions

Conclusã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.