This content is only available in Portuguese.

Not translated yet for this language.

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.

Equipe Blueprintblog9 min
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.ts
import { 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>
  )
}

Subscrição seletiva importa. Se você desestruturar a store inteira com useUIStore(), o componente re-renderiza em toda mudança de estado — mesmo as que não afetam ele. Prefira useUIStore(s => s.sidebarAberto) pra subscrever só ao que o componente usa.

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.ts
import { 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
// slice/produtos.ts
const 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.ts
export const buscarProdutos =
  createAsyncThunk(
    'produtos/buscar',
    async () => {
      const res = await fetch('/api/produtos')
      return res.json()
    }
  )

// componente
function 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
// store/filtro.ts
export const useFiltroStore =
  create()((set) => ({
    filtro: '',
    setFiltro: (f) =>
      set({ filtro: f }),
  }))

// hooks/useProdutos.ts
export function useProdutos() {
  return useQuery({
    queryKey: ['produtos'],
    queryFn: () =>
      fetch('/api/produtos')
        .then(r => r.json()),
  })
}

// componente
function 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ático
export 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ível
export 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 custo real dessa arquitetura é em times que não internalizaram a distinção entre estado de servidor e estado de cliente. Quando isso não está claro, respostas de API acabam no Zustand — e você volta ao problema original com uma nova camada de complexidade por cima.

O que fica depois de ler isso

  • Estado de servidor precisa de cache, refetch e retry. TanStack Query entrega tudo isso.
  • Estado de cliente é UI — sidebar, filtros, tema. Zustand gerencia com uma store que é um hook.
  • Subscrição seletiva no Zustand — use store(s => s.campo) em vez de destruturar tudo.
  • Query key com estado do Zustand — filtros na store viram dependências do cache, com refetch automático.
  • Update otimista — onMutate atualiza o cache imediatamente, onError faz rollback se necessário.
  • Não é pra todo projeto — apps pequenos, GraphQL ou Next.js com RSC têm soluções mais adequadas.

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.

Article tags

Related articles

Get the latest articles delivered to your inbox.

Follow Us: