Frontend

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.

Estado de Cliente e Servidor: Use Zustand e TanStack Query

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.

Estado de servidor

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.

Estado de cliente

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.

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

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

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

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

✕ Abordagem Redux
JAVASCRIPT
// 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 => ...)}    </>  )}
✓ Zustand + TanStack Query
JAVASCRIPT
// 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.

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

JAVASCRIPT
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? UseuseState. 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.