Nos artigos anteriores 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 (2021)
───────────────────── ──────────────────────────
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,
// ── Getters computados ───────────────────────
get estaLogado() {
return !!get().token && !!get().usuario;
},
get eAdmin() {
return get().usuario?.papel === 'admin';
},
// ── 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 anterior)
// - 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
Conclusão
Neste artigo você aprendeu:
- Por que Zustand e React Query resolvem problemas diferentes
- Zustand — criando stores simples e poderosas sem boilerplate
persistmiddleware — salvando estado no localStorage automaticamente- Seletores granulares — evitando re-renders desnecessários
- React Query — configurando o
QueryClientcom opções globais useQuery— buscando dados com cache, loading e erro automáticosuseMutation— criando, atualizando e removendo com feedback visual- Update otimista — atualiza a UI antes da resposta do servidor
- Prefetching — pré-carregando dados para navegação instantânea
- Como combinar os dois — Zustand para UI, React Query para servidor
No próximo artigo vamos aprender React Router — navegação entre páginas e construção de SPAs completas.
📌 Próximo artigo: Aula 41 — React Router: navegação em SPAs
📚 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 vs Redux: https://docs.pmnd.rs/zustand/getting-started/comparison
- Fluent React — Tejas Kumar (O'Reilly)
- roadmap.sh — React: https://roadmap.sh/react