Estado de Cliente e Servidor: Use Zustand e TanStack Query
O Redux não foi construído pra guardar a resposta da sua API. Foi construído pra gerenciar estado de interface. Em algum momento, esse limite desapareceu — e uma camada de boilerplate surgiu pra tapar o buraco que não deveria existir.

Dois problemas que pareciam um
Estado em uma aplicação React tem duas formas radicalmente diferentes. Misturá-las no mesmo lugar é a origem de boa parte da complexidade que você carrega em todo projeto.
Vive em um servidor remoto
Lista de produtos da API
Perfil do usuário logado
Histórico de pedidos, resultados de busca
Precisa de cache, refetch, deduplicação
Fica desatualizado. Falha. Precisa de retry.
Vive no browser
O sidebar está aberto ou fechado?
Qual aba está ativa?
O que o usuário digitou no filtro?
Tema, visibilidade do modal, step do wizard
Não precisa de cache nem de refetch
Quando os dois vão pro mesmo Redux slice — ou pra um Context gigante — você acaba escrevendo loading reducers, error reducers, lógica de cache e mecanismos de refetch na mão. Toda essa infraestrutura já existe em bibliotecas construídas especificamente pra estado de servidor.
A solução não foi trocar o Redux por algo melhor. Foi parar de pedir pro Redux resolver um problema que não é dele.
Zustand cuida do estado de cliente. TanStack Query cuida do estado de servidor. Cada um resolve exatamente um problema — e os dois não interferem um no outro.
Zustand: estado global sem cerimônia
Zustand parte de uma premissa: uma store é só um hook. Sem providers envolvendo o app inteiro. Sem actions, reducers ou dispatch. Sem boilerplate que existe apenas porque o padrão exige.
Estado e funções de atualização vivem juntos na mesma store. Componentes se inscrevem em fatias específicas e só re-renderizam quando aquela fatia muda.
// store/ui.tsimport { create } from 'zustand'interface UIState { sidebarAberto: boolean tema: 'claro' | 'escuro' alternarSidebar: () => void setTema: (tema: 'claro' | 'escuro') => void}export const useUIStore = create<UIState>()((set) => ({ sidebarAberto: false, tema: 'claro', alternarSidebar: () => set((state) => ({ sidebarAberto: !state.sidebarAberto })), setTema: (tema) => set({ tema }),}))Usando no componente — sem Provider, sem dispatch:
function Sidebar() { // subscrição seletiva: só re-renderiza quando sidebarAberto muda const sidebarAberto = useUIStore(s => s.sidebarAberto) const alternarSidebar = useUIStore(s => s.alternarSidebar) return ( <aside className={sidebarAberto ? 'aberto' : 'fechado'}> <button onClick={alternarSidebar}>Menu</button> </aside> )}TanStack Query: estado de servidor que se gerencia sozinho
TanStack Query trata dados de API como um problema de cache, não de estado. Cada chamada recebe uma chave de cache. A biblioteca decide quando buscar, quando servir do cache, quando refazer a requisição em background — e o que mostrar enquanto os dados estão carregando ou desatualizados.
// hooks/useProdutos.tsimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'export function useProdutos() { return useQuery({ queryKey: ['produtos'], queryFn: () => fetch('/api/produtos').then(r => r.json()), staleTime: 1000 * 60 * 5, // 5 min antes do refetch em background })}export function useCriarProduto() { const queryClient = useQueryClient() return useMutation({ mutationFn: (produto: NovoProduto) => fetch('/api/produtos', { method: 'POST', body: JSON.stringify(produto), }).then(r => r.json()), // invalida o cache após a mutação — dispara novo fetch onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['produtos'] }) }, })}O componente que consome fica limpo — sem gerenciar loading, error ou cache manualmente:
function ListaProdutos() { const { data: produtos, isPending, isError } = useProdutos() const criarProduto = useCriarProduto() if (isPending) return <Spinner /> if (isError) return <MensagemDeErro /> return ( <ul> {produtos.map(p => <ItemProduto key={p.id} produto={p} />)} </ul> )}O antes e o depois que mudou como eu penso em estado
Mesma feature — lista de produtos com filtro — usando Redux versus a combinação Zustand + TanStack Query.
// slice/produtos.tsconst produtosSlice = createSlice({ name: 'produtos', initialState: { itens: [], loading: false, error: null, filtro: '', }, reducers: { setFiltro: (state, action) => { state.filtro = action.payload }, }, extraReducers: (builder) => { builder .addCase(buscarProdutos.pending, (s) => { s.loading = true }) .addCase(buscarProdutos.fulfilled, (s, a) => { s.loading = false s.itens = a.payload }) .addCase(buscarProdutos.rejected, (s, a) => { s.loading = false s.error = a.error.message }) }})// thunk/produtos.tsexport const buscarProdutos = createAsyncThunk( 'produtos/buscar', async () => { const res = await fetch('/api/produtos') return res.json() } )// componentefunction ListaProdutos() { const dispatch = useDispatch() const { itens, loading, error, filtro } = useSelector(s => s.produtos) useEffect(() => { dispatch(buscarProdutos()) }, []) if (loading) return <Spinner /> if (error) return <Erro /> return ( <> <input value={filtro} onChange={e => dispatch( setFiltro(e.target.value) )} /> {itens .filter(p => p.nome.includes(filtro)) .map(p => ...)} </> )}// store/filtro.tsexport const useFiltroStore = create()((set) => ({ filtro: '', setFiltro: (f) => set({ filtro: f }), }))// hooks/useProdutos.tsexport function useProdutos() { return useQuery({ queryKey: ['produtos'], queryFn: () => fetch('/api/produtos') .then(r => r.json()), })}// componentefunction ListaProdutos() { const { data, isPending, isError } = useProdutos() const { filtro, setFiltro } = useFiltroStore() if (isPending) return <Spinner /> if (isError) return <Erro /> return ( <> <input value={filtro} onChange={e => setFiltro(e.target.value)} /> {data .filter(p => p.nome.includes(filtro)) .map(p => ...)} </> )}A versão Redux tem mais linhas. Mas o custo real não é de linhas — é de rastreabilidade. Pra entender o que o componente faz, você percorre actions, thunks, reducers e selectors antes de chegar ao render. Na versão com Zustand e TanStack Query, cada peça está onde o problema mora: busca de dados no hook de query, estado de UI na store, renderização no componente.
Quando os dois trabalham juntos: filtro que dispara refetch
O ponto forte aparece quando o estado do Zustand influencia as queries do TanStack Query. Um filtro na store vira dependência da chave de cache — o TanStack Query refaz a requisição automaticamente quando o valor muda.
// A chave inclui o filtro do Zustand// Quando filtro muda → refetch automáticoexport function useProdutosFiltrados() { const filtro = useFiltroStore(s => s.filtro) return useQuery({ queryKey: ['produtos', filtro], queryFn: () => fetch(`/api/produtos?q=${filtro}`).then(r => r.json()), // não busca com menos de 2 caracteres enabled: filtro.length > 2, })}// Query dependente: só busca os pedidos// depois que o usuário estiver disponívelexport function useDetalhesPedido(pedidoId: string) { const { data: usuario } = useUsuario() return useQuery({ queryKey: ['pedidos', pedidoId], queryFn: () => buscarPedido(pedidoId, usuario!.token), enabled: !!usuario, // só executa quando usuario existe })}Update otimista sem saga nem middleware
Atualizar a UI antes da resposta do servidor — update otimista — exigia sagas, thunks customizados ou middleware. No TanStack Query é configuração.
const alternarFavorito = useMutation({ mutationFn: (produtoId: string) => fetch(`/api/favoritos/${produtoId}`, { method: 'POST' }), // atualiza o cache imediatamente antes da requisição terminar onMutate: async (produtoId) => { await queryClient.cancelQueries({ queryKey: ['produtos'] }) const anterior = queryClient.getQueryData(['produtos']) queryClient.setQueryData(['produtos'], (old: Produto[]) => old.map(p => p.id === produtoId ? { ...p, favorito: !p.favorito } : p ) ) return { anterior } // snapshot pra rollback }, // se falhar, volta ao estado anterior onError: (err, produtoId, context) => { queryClient.setQueryData(['produtos'], context?.anterior) },})Quando essa combinação não faz sentido
Estado local resolve? Use
useState. Não adicione Zustand pra estado que não precisa ser compartilhado entre componentes.App pequeno, pouca interação com API? Zustand sozinho, sem TanStack Query, é suficiente.
Next.js com Server Components pesados? TanStack Query compete com RSC na busca de dados. Em apps muito orientados ao servidor, talvez não precise dele — ou use só pra mutações no cliente.
GraphQL? Apollo Client ou urql já gerenciam cache e estado de servidor. Adicionar TanStack Query cria redundância.
O boilerplate do Redux não era um problema de verbosidade. Era um sintoma de estar usando a ferramenta certa no lugar errado.
Estado de servidor tem ciclo de vida próprio — ele fica desatualizado, falha, precisa de retry, precisa de cache. Tratar isso como estado de UI significa reimplementar, na mão, o que o TanStack Query já resolve.
Quando cada problema tem a ferramenta certa, o código que sobra é só o que importa.


