Estado global com Zustand e React Query

[122] Estado global com Zustand e React Query

Tema, carrinho e filtro são seus; a lista de produtos é uma cópia que envelhece. A distinção decide a ferramenta: Zustand para o estado que nasce no navegador, com seletores granulares e persistência; React Query para o que vem da API, com cache, revalidação, mutação e update otimista.
Javascript

28 min de leitura

Nos artigos anteriores (Revisão + Projeto: API tipada e testada, Introdução ao React, React Hooks em profundidade) você aprendeu a gerenciar estado local com useState e useReducer, e estado compartilhado com useContext. Mas à medida que uma aplicação cresce, surgem dois problemas distintos que merecem ferramentas específicas:

Problema 1 — Estado de UI global: autenticação, carrinho de compras, tema, notificações. Estado que muitos componentes precisam ler e modificar.

Problema 2 — Estado de servidor: dados vindos de uma API. Esses dados têm necessidades únicas — cache, revalidação, sincronização, loading states, erros.

Zustand resolve o primeiro. React Query (TanStack Query) resolve o segundo. Juntos, são a combinação mais poderosa e elegante do ecossistema React em 2025.

Por que não Redux?

Redux (2015)             Zustand (2019)
─────────────────────    ──────────────────────────
Actions + Reducers       Estado + funções diretas
Boilerplate extenso      Mínimo de código
Middleware (Thunk/Saga)  Async nativo
~7KB                     ~1KB
Curva de aprendizado     Aprende em 10 minutos

Para a maioria dos projetos: Zustand é suficiente e muito mais simples.
Redux ainda faz sentido em aplicações muito grandes com equipes grandes.

Zustand — instalando e o primeiro store

npm install zustand
// src/stores/contadorStore.js — o mais simples possível
import { create } from 'zustand';

const useContadorStore = create((set) => ({
  // Estado
  contador: 0,

  // Ações — funções que modificam o estado
  incrementar: () => set((state) => ({ contador: state.contador + 1 })),
  decrementar: () => set((state) => ({ contador: state.contador - 1 })),
  resetar: () => set({ contador: 0 }),
  definir: (valor) => set({ contador: valor }),
}));

export default useContadorStore;
// Usando em qualquer componente — sem Provider!
import useContadorStore from './stores/contadorStore';

function Contador() {
  const contador = useContadorStore((state) => state.contador);
  const incrementar = useContadorStore((state) => state.incrementar);

  return (
    <div>
      <p>{contador}</p>
      <button onClick={incrementar}>+1</button>
    </div>
  );
}

function BotaoReset() {
  // Só este componente re-renderiza quando resetar for chamado
  const resetar = useContadorStore((state) => state.resetar);
  return <button onClick={resetar}>Resetar</button>;
}

// Sem Context, sem Provider, sem prop drilling
// Qualquer componente acessa o store diretamente

Store de autenticação — exemplo real

// src/stores/authStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// persist — salva o estado no localStorage automaticamente
const useAuthStore = create(
  persist(
    (set, get) => ({
      // ── Estado ──────────────────────────────────
      usuario: null,
      token: null,
      carregando: false,
      erro: null,

      // ── Valores derivados ────────────────────────
      // ATENÇÃO: aqui NÃO se usa `get estaLogado()`. O Zustand monta o próximo
      // estado com spread — { ...state, ...parcial } — e o spread INVOCA o
      // getter e grava o VALOR no lugar dele. Depois do primeiro set, o acessor
      // deixa de existir e estaLogado congela no que valia antes: false para
      // sempre, mesmo após um login bem-sucedido.
      //
      // Como função, o cálculo acontece na hora da chamada e o problema
      // desaparece:
      estaLogado: () => !!get().token && !!get().usuario,
      eAdmin: () => get().usuario?.papel === 'admin',
      // no componente:  const logado = useAuthStore((s) => !!s.token && !!s.usuario);
      // derivar no seletor é ainda melhor: o componente só re-renderiza quando
      // o booleano muda, não a cada mudança do store.

      // ── Ações ────────────────────────────────────
      login: async (email, senha) => {
        set({ carregando: true, erro: null });

        try {
          const res = await fetch('/api/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, senha }),
          });

          if (!res.ok) {
            const erro = await res.json();
            throw new Error(erro.erro || 'Credenciais inválidas.');
          }

          const { token, usuario } = await res.json();
          set({ token, usuario, carregando: false, erro: null });

          return { sucesso: true };
        } catch (erro) {
          set({ carregando: false, erro: erro.message });
          return { sucesso: false, erro: erro.message };
        }
      },

      logout: () => {
        set({ usuario: null, token: null, erro: null });
      },

      atualizarUsuario: (dados) => {
        set((state) => ({
          usuario: { ...state.usuario, ...dados },
        }));
      },

      limparErro: () => set({ erro: null }),
    }),
    {
      name: 'auth-storage',              // chave no localStorage
      partialize: (state) => ({          // persiste apenas token e usuario
        token: state.token,
        usuario: state.usuario,
      }),
    }
  )
);

export default useAuthStore;
// Usando o store de auth em qualquer lugar
import useAuthStore from '../stores/authStore';

function BarraNavegacao() {
  const usuario = useAuthStore((state) => state.usuario);
  const logout = useAuthStore((state) => state.logout);

  return (
    <nav>
      {usuario ? (
        <>
          <span>Olá, {usuario.nome}!</span>
          <button onClick={logout}>Sair</button>
        </>
      ) : (
        <a href="/login">Entrar</a>
      )}
    </nav>
  );
}

function PaginaLogin() {
  const { login, carregando, erro } = useAuthStore();
  const [email, setEmail] = useState('');
  const [senha, setSenha] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();
    const resultado = await login(email, senha);
    if (resultado.sucesso) {
      window.location.href = '/dashboard';
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} type="email" />
      <input value={senha} onChange={e => setSenha(e.target.value)} type="password" />
      {erro && <p className="erro">{erro}</p>}
      <button type="submit" disabled={carregando}>
        {carregando ? 'Entrando...' : 'Entrar'}
      </button>
    </form>
  );
}

Store de carrinho — estado complexo com Zustand

// src/stores/carrinhoStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCarrinhoStore = create(
  persist(
    (set, get) => ({
      itens: [],

      // ── Getters ──────────────────────────────────
      get totalItens() {
        return get().itens.reduce((acc, item) => acc + item.quantidade, 0);
      },

      get totalPreco() {
        return get().itens.reduce(
          (acc, item) => acc + item.preco * item.quantidade,
          0
        );
      },

      get estaVazio() {
        return get().itens.length === 0;
      },

      // ── Ações ────────────────────────────────────
      adicionarItem: (produto) => {
        set((state) => {
          const existente = state.itens.find((i) => i.id === produto.id);

          if (existente) {
            return {
              itens: state.itens.map((i) =>
                i.id === produto.id
                  ? { ...i, quantidade: i.quantidade + 1 }
                  : i
              ),
            };
          }

          return {
            itens: [...state.itens, { ...produto, quantidade: 1 }],
          };
        });
      },

      removerItem: (id) => {
        set((state) => ({
          itens: state.itens.filter((i) => i.id !== id),
        }));
      },

      atualizarQuantidade: (id, quantidade) => {
        if (quantidade <= 0) {
          get().removerItem(id);
          return;
        }
        set((state) => ({
          itens: state.itens.map((i) =>
            i.id === id ? { ...i, quantidade } : i
          ),
        }));
      },

      limpar: () => set({ itens: [] }),
    }),
    { name: 'carrinho-storage' }
  )
);

export default useCarrinhoStore;

Seletores — otimizando re-renders com Zustand

import useCarrinhoStore from '../stores/carrinhoStore';

// ✅ Selector granular — re-renderiza APENAS quando itens mudar
function BadgeCarrinho() {
  const totalItens = useCarrinhoStore((state) => state.totalItens);
  return <span className="badge">{totalItens}</span>;
}

// ✅ Selector de ação — nunca causa re-render (funções não mudam)
function BotaoAdicionarAoCarrinho({ produto }) {
  const adicionarItem = useCarrinhoStore((state) => state.adicionarItem);
  return (
    <button onClick={() => adicionarItem(produto)}>
      Adicionar ao carrinho
    </button>
  );
}

// ❌ Selector amplo — re-renderiza sempre que QUALQUER coisa no store mudar
function Errado() {
  const store = useCarrinhoStore(); // pega tudo!
  return <span>{store.totalItens}</span>;
}

React Query — estado de servidor

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
// src/main.jsx — configurando o QueryClient
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,      // dados "frescos" por 5 minutos
      gcTime: 1000 * 60 * 10,         // cache por 10 minutos
      retry: 2,                        // tenta 2x em caso de erro
      refetchOnWindowFocus: true,      // revalida ao focar a janela
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

useQuery — buscando dados

import { useQuery } from '@tanstack/react-query';

// Função de busca — separada do componente
async function buscarProdutos(filtros) {
  const params = new URLSearchParams(filtros);
  const res = await fetch(`/api/produtos?${params}`);
  if (!res.ok) throw new Error(`Erro ${res.status}`);
  return res.json();
}

async function buscarProdutoPorId(id) {
  const res = await fetch(`/api/produtos/${id}`);
  if (!res.ok) {
    if (res.status === 404) throw new Error('Produto não encontrado.');
    throw new Error(`Erro ${res.status}`);
  }
  return res.json();
}

// ── Listagem ────────────────────────────────────────
function ListaProdutos() {
  const [filtros, setFiltros] = useState({ categoria: '', pagina: 1 });

  const {
    data,           // dados retornados
    isLoading,      // true na primeira busca (sem cache)
    isFetching,     // true em qualquer busca (inclusive revalidação)
    isError,        // true se houve erro
    error,          // objeto de erro
    refetch,        // função para refazer manualmente
  } = useQuery({
    queryKey: ['produtos', filtros],    // chave única — muda → nova busca
    queryFn: () => buscarProdutos(filtros),
    placeholderData: (dadosAnteriores) => dadosAnteriores, // mantém dados anteriores durante paginação
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErroMensagem erro={error.message} onRetry={refetch} />;

  return (
    <div>
      {isFetching && <div className="indicador-atualizando">Atualizando...</div>}
      <ul>
        {data?.dados.map(p => (
          <li key={p._id}>{p.nome} — R$ {p.preco}</li>
        ))}
      </ul>
      <Paginacao
        total={data?.paginacao.total_paginas}
        atual={filtros.pagina}
        aoMudar={(p) => setFiltros(prev => ({ ...prev, pagina: p }))}
      />
    </div>
  );
}

// ── Detalhe com enabled ─────────────────────────────
function DetalheProduto({ id }) {
  const { data: produto, isLoading } = useQuery({
    queryKey: ['produtos', id],
    queryFn: () => buscarProdutoPorId(id),
    enabled: !!id,              // só busca se id existir
    staleTime: 1000 * 60 * 10, // cache de 10 min para detalhes
  });

  if (isLoading) return <p>Carregando...</p>;
  return <div><h1>{produto?.nome}</h1><p>R$ {produto?.preco}</p></div>;
}

useMutation — criando, atualizando e removendo

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

async function criarProduto(dados) {
  const res = await fetch('/api/produtos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(dados),
  });
  if (!res.ok) throw new Error('Erro ao criar produto.');
  return res.json();
}

async function atualizarProduto({ id, dados }) {
  const res = await fetch(`/api/produtos/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(dados),
  });
  if (!res.ok) throw new Error('Erro ao atualizar produto.');
  return res.json();
}

async function removerProduto(id) {
  const res = await fetch(`/api/produtos/${id}`, { method: 'DELETE' });
  if (!res.ok) throw new Error('Erro ao remover produto.');
  return res.json();
}

// ── Formulário de criação ───────────────────────────
function FormularioCriarProduto() {
  const queryClient = useQueryClient();
  const [form, setForm] = useState({ nome: '', preco: '', categoria: '' });

  const criacao = useMutation({
    mutationFn: criarProduto,

    // Chamado quando a mutation tem sucesso
    onSuccess: (novoProduto) => {
      // Invalida o cache de produtos — React Query revalida automaticamente
      queryClient.invalidateQueries({ queryKey: ['produtos'] });

      // Ou adiciona otimisticamente ao cache
      queryClient.setQueryData(['produtos', novoProduto._id], novoProduto);

      alert(`Produto "${novoProduto.nome}" criado com sucesso!`);
      setForm({ nome: '', preco: '', categoria: '' });
    },

    onError: (erro) => {
      alert(`Erro: ${erro.message}`);
    },
  });

  function handleSubmit(e) {
    e.preventDefault();
    criacao.mutate({ ...form, preco: Number(form.preco) });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Nome"
        value={form.nome}
        onChange={e => setForm(p => ({ ...p, nome: e.target.value }))}
      />
      <input
        placeholder="Preço"
        type="number"
        value={form.preco}
        onChange={e => setForm(p => ({ ...p, preco: e.target.value }))}
      />
      <button type="submit" disabled={criacao.isPending}>
        {criacao.isPending ? 'Criando...' : 'Criar Produto'}
      </button>
      {criacao.isError && <p className="erro">{criacao.error.message}</p>}
    </form>
  );
}

// ── Update otimista ─────────────────────────────────
function ItemProduto({ produto }) {
  const queryClient = useQueryClient();

  const atualizacao = useMutation({
    mutationFn: atualizarProduto,

    // Atualiza o cache ANTES da resposta do servidor
    onMutate: async ({ id, dados }) => {
      // Cancela queries em andamento para evitar conflito
      await queryClient.cancelQueries({ queryKey: ['produtos'] });

      // Salva estado anterior para rollback
      const anterior = queryClient.getQueryData(['produtos']);

      // Atualiza otimisticamente
      queryClient.setQueryData(['produtos'], (old) => ({
        ...old,
        dados: old.dados.map(p =>
          p._id === id ? { ...p, ...dados } : p
        ),
      }));

      return { anterior }; // contexto para onError
    },

    // Se der erro, desfaz
    onError: (_erro, _vars, contexto) => {
      queryClient.setQueryData(['produtos'], contexto.anterior);
    },

    // Revalida após sucesso ou erro
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['produtos'] });
    },
  });

  return (
    <li>
      {produto.nome}
      <button
        onClick={() => atualizacao.mutate({
          id: produto._id,
          dados: { ativo: !produto.ativo },
        })}
      >
        {produto.ativo ? 'Desativar' : 'Ativar'}
      </button>
    </li>
  );
}

Combinando Zustand + React Query

A separação de responsabilidades fica clara:

// src/hooks/useProdutos.js — hook que combina os dois
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import useAuthStore from '../stores/authStore';

// Zustand fornece o token de autenticação
// React Query gerencia os dados do servidor

function useProdutos(filtros = {}) {
  const token = useAuthStore((state) => state.token);
  const queryClient = useQueryClient();

  // Headers autenticados — vêm do Zustand
  const headers = {
    'Content-Type': 'application/json',
    ...(token && { Authorization: `Bearer ${token}` }),
  };

  // Busca — React Query
  const listagem = useQuery({
    queryKey: ['produtos', filtros],
    queryFn: async () => {
      const params = new URLSearchParams(filtros);
      const res = await fetch(`/api/produtos?${params}`, { headers });
      if (!res.ok) throw new Error('Erro ao buscar produtos.');
      return res.json();
    },
    enabled: !!token, // só busca se estiver logado
  });

  // Criação — React Query Mutation
  const criar = useMutation({
    mutationFn: async (dados) => {
      const res = await fetch('/api/produtos', {
        method: 'POST',
        headers,
        body: JSON.stringify(dados),
      });
      if (!res.ok) throw new Error('Erro ao criar produto.');
      return res.json();
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['produtos'] }),
  });

  // Remoção — React Query Mutation
  const remover = useMutation({
    mutationFn: async (id) => {
      const res = await fetch(`/api/produtos/${id}`, {
        method: 'DELETE',
        headers,
      });
      if (!res.ok) throw new Error('Erro ao remover produto.');
      return res.json();
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['produtos'] }),
  });

  return { listagem, criar, remover };
}

// Uso no componente — interface limpa
function PainelProdutos() {
  const [filtros, setFiltros] = useState({});
  const { listagem, criar, remover } = useProdutos(filtros);

  if (listagem.isLoading) return <Skeleton />;
  if (listagem.isError) return <p>{listagem.error.message}</p>;

  return (
    <div>
      <button
        onClick={() => criar.mutate({ nome: 'Novo', preco: 100, categoria: 'outros' })}
        disabled={criar.isPending}
      >
        {criar.isPending ? 'Criando...' : 'Novo Produto'}
      </button>

      <ul>
        {listagem.data?.dados.map(p => (
          <li key={p._id}>
            {p.nome}
            <button onClick={() => remover.mutate(p._id)}>🗑</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Prefetching e cache avançado

import { useQueryClient } from '@tanstack/react-query';

// Pré-carregar dados ao passar o mouse
function LinkProduto({ id, nome }) {
  const queryClient = useQueryClient();

  function preCarregar() {
    queryClient.prefetchQuery({
      queryKey: ['produtos', id],
      queryFn: () => buscarProdutoPorId(id),
      staleTime: 1000 * 60 * 5,
    });
  }

  return (
    <a
      href={`/produtos/${id}`}
      onMouseEnter={preCarregar}  // pré-carrega ao passar o mouse
    >
      {nome}
    </a>
  );
}

// Invalidação seletiva
queryClient.invalidateQueries({ queryKey: ['produtos'] });           // todos
queryClient.invalidateQueries({ queryKey: ['produtos', 'lista'] }); // só lista
queryClient.invalidateQueries({ queryKey: ['produtos', id] });      // só um item

// Manipulação direta do cache
queryClient.setQueryData(['produtos', id], novoValor);
queryClient.removeQueries({ queryKey: ['produtos'] });

Tarefa para você

Construa um painel de e-commerce conectando a API do Módulo 4 ao React:

// 1. Configure Zustand + React Query no projeto Vite

// 2. Store de autenticação (Zustand):
//    - login, logout, persistência com localStorage
//    - token usado em todas as requisições

// 3. React Query para produtos:
//    useQuery: listar com filtros e paginação
//    useMutation: criar, atualizar, remover
//    Update otimista ao toggle de ativo/inativo

// 4. React Query para tarefas:
//    - Listagem com filtro por status
//    - Criar tarefa com formulário
//    - Marcar como concluída (update otimista)

// 5. Componente de busca com debounce:
//    - Input de busca (useDebounce 500ms do artigo React Hooks em profundidade)
//    - queryKey inclui o termo de busca
//    - placeholderData mantém dados anteriores enquanto busca

// 6. DevTools:
//    - Instale @tanstack/react-query-devtools
//    - Observe as queries, cache e invalidações em tempo real
Ver solução — o painel conectado à API, com os 10 testes que o provam
// ---- src/loja/authStore.js
// 2 — STORE DE AUTENTICAÇÃO (Zustand + persist)
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

export const useAuth = create(
  persist(
    (set) => ({
      token: null,
      usuario: null,
      autenticado: false,

      login: (token, usuario) => set({ token, usuario, autenticado: true }),
      logout: () => set({ token: null, usuario: null, autenticado: false }),
    }),
    {
      name: "ecommerce:auth",
      storage: createJSONStorage(() => localStorage),
      // Sem partialize, TUDO vai para o localStorage — inclusive as funções
      // (que viram undefined) e qualquer estado transitório que você
      // adicionar depois sem lembrar deste arquivo.
      partialize: (estado) => ({
        token: estado.token,
        usuario: estado.usuario,
        autenticado: estado.autenticado,
      }),
    }
  )
);

// Seletor fora do componente: `useAuth(pegarToken)` não re-renderiza quando
// outra fatia da store muda.
export const pegarToken = (estado) => estado.token;
export const pegarAutenticado = (estado) => estado.autenticado;

// ---- src/api/config.js
// A URL base entra por injeção, e não por `import.meta.env` lido no meio do
// módulo. Motivo prático: `import.meta` é sintaxe de ES Module — o Babel/Jest
// nem consegue transformar o arquivo, e o teste morre antes de rodar. É por
// isso que projeto Vite costuma usar Vitest. Injetando, o mesmo código serve
// aos dois: main.jsx chama configurarApi(import.meta.env.VITE_API_URL).
let base = "http://localhost:3000";

export function configurarApi(novaBase) {
  if (novaBase) base = novaBase.replace(/\/+$/, "");
}

export function urlBase() {
  return base;
}

// ---- src/api/cliente.js
// O cliente HTTP que usa o token da store
import { useAuth } from "../loja/authStore";
import { urlBase } from "./config";

export async function api(caminho, opcoes = {}) {
  // getState(), e não o hook: isto roda fora de componente.
  const token = useAuth.getState().token;

  const resposta = await fetch(`${urlBase()}${caminho}`, {
    ...opcoes,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...opcoes.headers,
    },
  });

  if (resposta.status === 401) {
    // 401 em qualquer rota derruba a sessão num lugar só — não em cada tela.
    useAuth.getState().logout();
    throw new Error("Sessão expirada. Faça login novamente.");
  }

  if (!resposta.ok) {
    const corpo = await resposta.json().catch(() => ({}));
    throw new Error(corpo.erro || `HTTP ${resposta.status}`);
  }

  return resposta.status === 204 ? null : resposta.json();
}

// ---- src/query/produtos.js
// 3 — REACT QUERY: listagem, mutations e update otimista
//
// O mesmo desenho serve ao item 4 (tarefas): troque a rota e as chaves.
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { api } from "../api/cliente";

export const chavesProdutos = {
  todos: ["produtos"],
  lista: (filtros) => ["produtos", "lista", filtros],
  detalhe: (id) => ["produtos", "detalhe", id],
};

export function useProdutos(filtros = {}) {
  return useQuery({
    // O filtro FAZ PARTE da chave: sem isso o cache devolve a busca
    // anterior enquanto a nova ainda está no ar.
    queryKey: chavesProdutos.lista(filtros),
    queryFn: () => api(`/produtos?${new URLSearchParams(filtros)}`),
    // Mantém a página anterior visível durante a nova busca, em vez de
    // piscar o estado de carregamento a cada tecla.
    placeholderData: keepPreviousData,
  });
}

export function useCriarProduto() {
  const cliente = useQueryClient();
  return useMutation({
    mutationFn: (dados) => api("/produtos", { method: "POST", body: JSON.stringify(dados) }),
    onSuccess: () => cliente.invalidateQueries({ queryKey: chavesProdutos.todos }),
  });
}

export function useAlternarAtivo(filtros = {}) {
  const cliente = useQueryClient();
  const chave = chavesProdutos.lista(filtros);

  return useMutation({
    mutationFn: ({ id, ativo }) =>
      api(`/produtos/${id}`, { method: "PATCH", body: JSON.stringify({ ativo }) }),

    // ---- update otimista, os quatro passos ----
    onMutate: async ({ id, ativo }) => {
      // 1. cancelar buscas em voo, senão a resposta antiga sobrescreve o otimismo
      await cliente.cancelQueries({ queryKey: chave });

      // 2. guardar o estado atual para o rollback
      const anterior = cliente.getQueryData(chave);

      // 3. aplicar a mudança na hora
      cliente.setQueryData(chave, (antigo) =>
        antigo
          ? { ...antigo, dados: antigo.dados.map((p) => (p._id === id ? { ...p, ativo } : p)) }
          : antigo
      );

      return { anterior };
    },

    // 4. desfazer se o servidor recusar
    onError: (_erro, _variaveis, contexto) => {
      if (contexto?.anterior) cliente.setQueryData(chave, contexto.anterior);
    },

    // Sempre revalidar no fim: o otimismo é um palpite, não a verdade.
    onSettled: () => cliente.invalidateQueries({ queryKey: chave }),
  });
}

// ---- src/componentes/BuscaProdutos.jsx
// 5 — BUSCA COM DEBOUNCE DE 500ms
import { useState } from "react";
import { useDebounce } from "../hooks/useDebounce";
import { useProdutos, useAlternarAtivo } from "../query/produtos";

export function BuscaProdutos() {
  const [termo, setTermo] = useState("");
  const busca = useDebounce(termo, 500);
  const filtros = busca ? { busca } : {};

  const { data, isPending, isError, error, isPlaceholderData } = useProdutos(filtros);
  const alternar = useAlternarAtivo(filtros);

  return (
    <div>
      <input
        type="search"
        value={termo}
        onChange={(e) => setTermo(e.target.value)}
        placeholder="Buscar produtos..."
        aria-label="Buscar produtos"
      />

      {isPending && <p>Carregando...</p>}
      {isError && <p role="alert">{error.message}</p>}

      {/* isPlaceholderData: os dados na tela são da busca ANTERIOR */}
      <ul aria-busy={isPlaceholderData}>
        {data?.dados?.map((p) => (
          <li key={p._id}>
            <span>{p.nome}</span>
            <button onClick={() => alternar.mutate({ id: p._id, ativo: !p.ativo })}>
              {p.ativo ? "Desativar" : "Ativar"}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 6 — DEVTOOLS
//
// // ---- src/main.jsx
// import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
//
// const cliente = new QueryClient({
//   defaultOptions: {
//     queries: {
//       staleTime: 30_000,   // 30s sem refetch: o padrão é 0, e sem isto
//                            // toda volta de foco na janela refaz a busca
//       retry: 1,
//     },
//   },
// });
//
// <QueryClientProvider client={cliente}>
//   <App />
//   <ReactQueryDevtools initialIsOpen={false} />
// </QueryClientProvider>

// ---- testes/estado122.test.jsx
// OS TESTES — 10, todos passando
import { render, screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAuth } from "../src/loja/authStore";
import { api } from "../src/api/cliente";
import { BuscaProdutos } from "../src/componentes/BuscaProdutos";

function envolver(ui, cliente) {
  return render(<QueryClientProvider client={cliente}>{ui}</QueryClientProvider>);
}

function clienteDeTeste() {
  // retry: false — senão cada teste de erro espera os 3 retries padrão.
  return new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  });
}

const PRODUTOS = {
  dados: [
    { _id: "a1", nome: "Teclado", ativo: true },
    { _id: "a2", nome: "Mouse", ativo: false },
  ],
};

beforeEach(() => {
  localStorage.clear();
  useAuth.setState({ token: null, usuario: null, autenticado: false });
  global.fetch = jest.fn();
});

describe("2 — store de autenticação (Zustand)", () => {
  it("login preenche o estado e persiste", () => {
    act(() => useAuth.getState().login("jwt-123", { nome: "Ana" }));

    expect(useAuth.getState().autenticado).toBe(true);
    const salvo = JSON.parse(localStorage.getItem("ecommerce:auth"));
    expect(salvo.state.token).toBe("jwt-123");
  });

  it("logout limpa o estado", () => {
    act(() => useAuth.getState().login("jwt-123", { nome: "Ana" }));
    act(() => useAuth.getState().logout());
    expect(useAuth.getState().token).toBeNull();
    expect(useAuth.getState().autenticado).toBe(false);
  });

  it("partialize não grava as funções no localStorage", () => {
    act(() => useAuth.getState().login("t", { nome: "Ana" }));
    const salvo = JSON.parse(localStorage.getItem("ecommerce:auth"));
    expect(Object.keys(salvo.state).sort()).toEqual(["autenticado", "token", "usuario"]);
  });
});

describe("2b — o token vai em toda requisição", () => {
  it("manda Authorization quando há token", async () => {
    act(() => useAuth.getState().login("jwt-abc", { nome: "Ana" }));
    global.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => PRODUTOS });

    await api("/produtos");

    const [, opcoes] = global.fetch.mock.calls[0];
    expect(opcoes.headers.Authorization).toBe("Bearer jwt-abc");
  });

  it("não manda Authorization sem token", async () => {
    global.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
    await api("/produtos");
    expect(global.fetch.mock.calls[0][1].headers.Authorization).toBeUndefined();
  });

  it("401 derruba a sessão", async () => {
    act(() => useAuth.getState().login("expirado", { nome: "Ana" }));
    global.fetch.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });

    await expect(api("/produtos")).rejects.toThrow(/Sess/);
    expect(useAuth.getState().autenticado).toBe(false);
  });
});

describe("3 e 5 — React Query: busca com debounce e chave por filtro", () => {
  it("só busca depois dos 500ms e a chave inclui o termo", async () => {
    jest.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    global.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => PRODUTOS });

    envolver(<BuscaProdutos />, clienteDeTeste());
    await act(async () => {});                       // deixa a query inicial resolver

    const chamadasIniciais = global.fetch.mock.calls.length;
    await user.type(screen.getByLabelText("Buscar produtos"), "tec");
    expect(global.fetch.mock.calls.length).toBe(chamadasIniciais);   // ainda nada

    await act(async () => { jest.advanceTimersByTime(500); });

    const url = global.fetch.mock.calls.at(-1)[0];
    expect(url).toContain("busca=tec");
    jest.useRealTimers();
  });

  it("mostra o erro da API", async () => {
    global.fetch.mockResolvedValue({
      ok: false, status: 500, json: async () => ({ erro: "Banco fora do ar" }),
    });

    envolver(<BuscaProdutos />, clienteDeTeste());
    expect(await screen.findByRole("alert")).toHaveTextContent("Banco fora do ar");
  });
});

describe("3b — update otimista", () => {
  it("troca na tela antes da resposta do servidor", async () => {
    const user = userEvent.setup();
    let resolverPatch;
    global.fetch.mockImplementation((url, opcoes = {}) => {
      if (opcoes.method === "PATCH") {
        return new Promise((r) => { resolverPatch = () => r({ ok: true, status: 200, json: async () => ({}) }); });
      }
      return Promise.resolve({ ok: true, status: 200, json: async () => PRODUTOS });
    });

    envolver(<BuscaProdutos />, clienteDeTeste());
    await screen.findByText("Teclado");

    await user.click(screen.getAllByRole("button", { name: "Desativar" })[0]);

    // o botão já virou "Ativar", com o PATCH ainda pendurado
    await waitFor(() =>
      expect(screen.getAllByRole("button", { name: "Ativar" }).length).toBe(2)
    );

    await act(async () => { resolverPatch(); });
  });

  it("desfaz quando o servidor recusa", async () => {
    const user = userEvent.setup();
    global.fetch.mockImplementation((url, opcoes = {}) => {
      if (opcoes.method === "PATCH") {
        return Promise.resolve({ ok: false, status: 422, json: async () => ({ erro: "não permitido" }) });
      }
      return Promise.resolve({ ok: true, status: 200, json: async () => PRODUTOS });
    });

    envolver(<BuscaProdutos />, clienteDeTeste());
    await screen.findByText("Teclado");

    await user.click(screen.getAllByRole("button", { name: "Desativar" })[0]);

    // volta a "Desativar": o rollback do onError restaurou o estado anterior
    await waitFor(() =>
      expect(screen.getAllByRole("button", { name: "Desativar" }).length).toBe(1)
    );
  });
});

// ---- saída real
// Test Suites: 1 passed, 1 total
// Tests:       10 passed, 10 total
// Time:        1.085 s

Zustand e React Query não competem: um guarda o estado do cliente (quem está logado, qual o tema), o outro guarda cópia de estado do servidor (produtos, tarefas). Jogar a lista de produtos dentro do Zustand é o erro clássico — você reescreve à mão cache, revalidação e deduplicação que o React Query já faz. E o update otimista só está completo com os quatro passos: cancelQueries (senão uma resposta em voo sobrescreve o otimismo), guardar o anterior, aplicar, e onError restaurando. Faltando o cancelQueries, o bug aparece uma vez a cada vinte e é impossível de reproduzir à mão.

A distinção que organiza este artigo é entre o estado que é seu e o estado que é emprestado. Tema, filtro selecionado e modal aberto pertencem à interface e cabem no Zustand. Lista de usuários, detalhe de pedido e qualquer coisa vinda da API pertencem ao servidor: o que você tem é uma cópia, ela envelhece, e cuidar disso — cache, revalidação, carregamento e erro — é precisamente o serviço que o React Query presta.

Fontes e Referências

Exercícios

Exercício 1

O login funciona, o token é salvo, o nome do usuário aparece na barra — mas estaLogado continua false e as rotas protegidas nunca liberam. O que aconteceu?

const useAuthStore = create((set, get) => ({
  usuario: null,
  token: null,

  get estaLogado() {
    return !!get().token && !!get().usuario;
  },

  login: async (email, senha) => {
    set({ carregando: true });
    const { token, usuario } = await autenticar(email, senha);
    set({ token, usuario, carregando: false });
  },
}));
Ver resposta

✓ Resposta: O getter deixou de existir no primeiro set. O Zustand monta o próximo estado espalhando o anterior — algo equivalente a { ...state, ...parcial } —, e o spread não copia acessores: ele invoca o getter e grava o valor resultante como propriedade comum. Na primeira chamada, set({ carregando: true }), o estaLogado é avaliado com token nulo, resulta false, e o que vai para o novo estado é o booleano false — não a função. Dali em diante nada recalcula: o segundo set, que traz o token de verdade, apenas copia o false adiante. O sintoma é cruel porque tudo o mais funciona; só a propriedade derivada mente. A correção é não declarar valor derivado como getter dentro do store. Como funçãoestaLogado: () => !!get().token && !!get().usuario — o cálculo acontece na chamada e o problema some. Melhor ainda é derivar no seletor: useAuthStore((s) => !!s.token && !!s.usuario), porque aí o componente só re-renderiza quando o booleano muda, e não a cada alteração do store. A lição geral vale além do Zustand: qualquer biblioteca que atualize estado por spread destrói getters, e é por isso que estado deve guardar dados, deixando o que é calculado para o momento da leitura.

Exercício 2

Este componente só mostra o nome do usuário, mas re-renderiza quando o carrinho muda, quando o tema muda, quando qualquer coisa no store muda. Por quê?

function Saudacao() {
  const { usuario } = useAppStore();
  return <span>Olá, {usuario?.nome}</span>;
}
Ver resposta

✓ Resposta: Porque chamar o hook sem seletor assina o store inteiro. useAppStore() devolve o objeto de estado completo, e o Zustand notifica o componente sempre que esse objeto muda — o que acontece a cada set, venha de onde vier. A desestruturação não ajuda: ela acontece depois, já com o objeto em mãos, e o Zustand não tem como saber que você só queria o usuario. Com o seletor, useAppStore((s) => s.usuario), a biblioteca compara apenas o valor selecionado e pula a renderização quando ele não mudou. É essa granularidade que faz o Zustand ser mais eficiente que o Context para estado global — mas ela é opcional, e quem esquece o seletor perde exatamente a vantagem que foi buscar. Uma armadilha vizinha: selecionar um objeto ou array montado na hora, como useAppStore((s) => ({ nome: s.usuario.nome, tema: s.tema })), volta a re-renderizar sempre, porque a comparação é por identidade e o objeto é novo a cada chamada. Nesse caso ou se usam dois seletores separados, ou o useShallow, que compara campo a campo.

Exercício 3

Quais destes pertencem ao Zustand e quais pertencem ao React Query? Justifique o critério.

// A — o tema claro/escuro escolhido pelo usuário
// B — a lista de produtos vinda de GET /api/produtos
// C — o carrinho de compras, antes de finalizar
// D — o detalhe do pedido 4821
// E — o filtro selecionado na barra lateral
// F — os dados do usuário logado, vindos de GET /api/eu
Ver resposta

✓ Resposta: Zustand para A, C e E; React Query para B, D e F. O critério não é "global ou local", é de quem é a fonte da verdade. Tema, carrinho em edição e filtro nascem no navegador, ninguém mais os conhece, e o que está na memória é a versão correta por definição — é estado de cliente. Já lista de produtos, detalhe de pedido e perfil pertencem ao servidor: o que você tem é uma cópia, ela envelhece no instante em que chega, e outra pessoa pode alterá-la sem que você saiba. Cuidar dessa cópia é um problema inteiro — cache, revalidação, saber se está obsoleta, refazer a busca quando a aba volta ao foco, deduplicar requisições simultâneas, tratar carregamento e erro, repetir em caso de falha — e é exatamente esse problema que o React Query resolve. Guardar dados de servidor no Zustand significa reescrever tudo isso à mão, mal. O caso F costuma gerar dúvida: o token é de cliente e vai para o Zustand, com persist; os dados do perfil vêm da API e vão para o React Query. E o C muda de lado no momento em que o carrinho passa a ser salvo no servidor — a pergunta a fazer é sempre "se eu recarregar a página em outro dispositivo, esse dado ainda existe?".

Exercício 4

A mutação cria o produto no servidor, mas a lista na tela continua sem ele até o usuário atualizar a página. O que falta?

const { data: produtos } = useQuery({
  queryKey: ['produtos'],
  queryFn: buscarProdutos,
});

const criar = useMutation({
  mutationFn: (novo) => fetch('/api/produtos', {
    method: 'POST',
    body: JSON.stringify(novo),
  }),
});
Ver resposta

✓ Resposta: Falta invalidar a consulta depois que a mutação termina. O React Query mantém um cache indexado pela queryKey, e ele não tem como adivinhar que um POST em /api/produtos torna obsoleta a lista guardada sob ['produtos'] — essa ligação é você quem declara. A correção é o onSuccess: queryClient.invalidateQueries({ queryKey: ['produtos'] }), que marca o cache como desatualizado e refaz a busca automaticamente para quem estiver usando aquela chave. Esse é o fluxo padrão de toda escrita: mutação, invalidação, atualização da tela. Duas observações completam o quadro. A primeira é sobre o desenho da queryKey: usar chaves hierárquicas — ['produtos', 'lista', filtros] e ['produtos', 'detalhe', id] — permite invalidar por prefixo e derrubar de uma vez todas as listagens, com qualquer filtro, sem tocar nos detalhes. A segunda é que existe um caminho mais rápido para a percepção do usuário, o update otimista: escrever no cache antes da resposta do servidor, com setQueryData, e desfazer no onError. Ele elimina a espera, ao custo de precisar tratar o caso em que a operação falha.

Exercício 5

Duas configurações de staleTime. Que diferença de comportamento o usuário percebe entre elas?

// A
new QueryClient();  // staleTime padrão

// B
new QueryClient({
  defaultOptions: { queries: { staleTime: 1000 * 60 * 5 } },
});
Ver resposta

✓ Resposta: Em A, o staleTime padrão é zero: todo dado nasce obsoleto no instante em que chega. Isso significa que voltar para uma tela já visitada, trocar de aba e voltar, ou montar um segundo componente com a mesma chave dispara uma nova requisição — o usuário vê os dados do cache imediatamente, sem tela em branco, e eles são substituídos assim que a resposta nova chega. Em B, durante cinco minutos o dado é considerado fresco e nenhuma dessas situações provoca requisição. A diferença prática aparece no volume de tráfego e na percepção de atualidade: o padrão é conservador e pode gerar muito mais chamadas do que o necessário num app com navegação intensa; cinco minutos deixa o app silencioso, com o risco de mostrar informação velha. A escolha é por natureza do dado — cotação e estoque pedem zero, lista de categorias e perfil suportam minutos. Vale não confundir com o gcTime, que responde outra pergunta: staleTime é "por quanto tempo confio nesta cópia sem verificar"; gcTime é "por quanto tempo guardo esta cópia depois que ninguém mais a usa" — e é ele que decide se, ao voltar para a tela, você vê dados antigos na hora ou um indicador de carregamento.

Comentários

Mais em Javascript

Deploy: do código ao ar
Deploy: do código ao ar

Construir é metade do trabalho; a outra metade é colocar no ar. O artigo…

Arrays: criando e manipulando listas
Arrays: criando e manipulando listas

O sort() sem argumento ordena como se tudo fosse texto, e aí 10 vem antes de…

MongoDB e Mongoose: banco de dados com Node
MongoDB e Mongoose: banco de dados com Node

Um array na memória some quando o servidor reinicia, e é aí que entra o banco…