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
- Zustand — Documentação: https://zustand.docs.pmnd.rs
- TanStack Query — Documentação: https://tanstack.com/query/latest
- TanStack Query — Guia de início: https://tanstack.com/query/latest/docs/framework/react/quick-start
- TanStack Query — Mutations: https://tanstack.com/query/latest/docs/framework/react/guides/mutations
- TanStack Query — Optimistic Updates: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
- Zustand — repositório oficial, com a comparação com Redux no README: https://github.com/pmndrs/zustand
- Fluent React — Tejas Kumar (O'Reilly)
- React — gerenciando estado: https://react.dev/learn/managing-state
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ção — estaLogado: () => !!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.