Saltar para o conteúdo
TanStack Query vs Zustand: Quando usar cada um
React

TanStack Query vs Zustand: Quando usar cada um

28 de outubro de 2024·Paulo de Paula

A pergunta aparece toda semana no Discord e no Stack Overflow: “devo usar Redux, Zustand ou TanStack Query?” A resposta depende de entender que existem dois tipos completamente diferentes de estado.

Estado de servidor vs estado de cliente

Estado de servidor — dados que vivem no backend:

  • Lista de usuários do banco de dados
  • Pedidos de um cliente
  • Configurações salvas

Características: assíncrono, potencialmente desatualizado, compartilhado com outros clientes.

Estado de cliente — dados que existem só no browser:

  • Modal aberto/fechado
  • Etapa atual de um wizard
  • Tema selecionado pelo usuário
  • Carrinho de compras (antes de salvar)

Características: síncrono, sempre atualizado, privado do usuário atual.

A regra de ouro

TanStack Query para estado de servidor. Zustand (ou useState) para estado de cliente.

Misturar os dois é o erro mais comum — e é o que deixa o código complexo sem necessidade.

TanStack Query na prática

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// Buscar dados
function ListaUsuarios() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['usuarios'],
    queryFn: () => fetch('/api/usuarios').then(r => r.json()),
    staleTime: 1000 * 60 * 5, // dados frescos por 5 minutos
  })

  if (isLoading) return <Skeleton />
  if (error) return <Erro mensagem={error.message} />

  return <ul>{data.map(u => <li key={u.id}>{u.nome}</li>)}</ul>
}

// Mutação com invalidação automática
function FormNovoUsuario() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (novoUsuario) =>
      fetch('/api/usuarios', {
        method: 'POST',
        body: JSON.stringify(novoUsuario),
      }).then(r => r.json()),
    onSuccess: () => {
      // Invalida o cache — React Query rebusca automaticamente
      queryClient.invalidateQueries({ queryKey: ['usuarios'] })
    },
  })

  const handleSubmit = (e) => {
    e.preventDefault()
    mutation.mutate({ nome: e.target.nome.value })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="nome" />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Salvando...' : 'Salvar'}
      </button>
    </form>
  )
}

O que o TanStack Query resolve por você:

  • Cache automático
  • Deduplicação de requisições paralelas
  • Revalidação em foco de janela
  • Retry com backoff exponencial
  • Estado de loading/error/success

Zustand para estado de cliente

import { create } from 'zustand'

interface UIStore {
  modalAberto: boolean
  etapaAtual: number
  abrirModal: () => void
  fecharModal: () => void
  avancarEtapa: () => void
  voltarEtapa: () => void
}

const useUIStore = create<UIStore>((set) => ({
  modalAberto: false,
  etapaAtual: 1,
  abrirModal: () => set({ modalAberto: true }),
  fecharModal: () => set({ modalAberto: false, etapaAtual: 1 }),
  avancarEtapa: () => set((state) => ({ etapaAtual: state.etapaAtual + 1 })),
  voltarEtapa: () => set((state) => ({ etapaAtual: state.etapaAtual - 1 })),
}))

// Uso nos componentes
function Wizard() {
  const { etapaAtual, avancarEtapa, voltarEtapa } = useUIStore()

  return (
    <div>
      <p>Etapa {etapaAtual} de 3</p>
      <button onClick={voltarEtapa} disabled={etapaAtual === 1}>Voltar</button>
      <button onClick={avancarEtapa} disabled={etapaAtual === 3}>Avançar</button>
    </div>
  )
}

O anti-padrão mais comum

Colocar dados do servidor no Zustand:

// Não faça isso
const useStore = create((set) => ({
  usuarios: [],
  carregarUsuarios: async () => {
    const data = await fetch('/api/usuarios').then(r => r.json())
    set({ usuarios: data })
  },
}))

Você vai precisar reinventar tudo que o TanStack Query já resolve: cache, loading state, error handling, invalidação, refetch…

Quando usar useState no lugar de Zustand?

useState é suficiente para estado local que não é compartilhado:

function Accordion({ titulo, conteudo }) {
  const [aberto, setAberto] = useState(false) // local, sem compartilhamento
  return (
    <div>
      <button onClick={() => setAberto(!aberto)}>{titulo}</button>
      {aberto && <p>{conteudo}</p>}
    </div>
  )
}

Escale para Zustand só quando o estado precisar ser compartilhado entre componentes distantes na árvore.

Resumo da decisão

SituaçãoSolução
Dados da APITanStack Query
Formulário localuseState / React Hook Form
Estado compartilhado da UIZustand
Estado global de autenticaçãoZustand
Cache de dados do servidorTanStack Query
Paginação/filtros de listaTanStack Query

Adote essa separação e o gerenciamento de estado no React se torna muito mais simples.