Gerenciamento de Estado: Context API vs Zustand vs React Query
Quando usar Context API, Zustand ou React Query. Otimizando re-renders com useMemo e useCallback, o padrão de contextos separados por domínio e por que estado de servidor não deve viver em estado global.
Gerenciamento de estado é um dos tópicos mais mal compreendidos no React. A confusão principal está em tratar todo estado da mesma forma, quando na verdade existem categorias distintas com ferramentas diferentes: estado local (useState), estado de servidor (React Query/SWR), estado de UI global (Context API), e estado de cliente compartilhado complexo (Zustand/Redux Toolkit).
A escolha errada da ferramenta cria problemas difíceis: Context API para estado de servidor causa re-renders excessivos e invalidação complexa. Redux para tudo gera boilerplate desnecessário. Este artigo mapeia cada categoria ao padrão certo.
As Quatro Categorias de Estado
- Estado Local — pertence a um único componente.
useState,useReducer. Exemplos: input de formulário, toggle de modal, valor de contador. - Estado de Servidor — dados que vivem no servidor e são cacheados no cliente. React Query ou SWR. Exemplos: lista de produtos, perfil do usuário, pedidos. Tem loading, error, cache, refetch automático.
- Estado de UI Global — estado de interface compartilhado entre componentes distantes. Context API. Exemplos: tema (dark/light), usuário autenticado, fila de toasts.
- Estado de Cliente Compartilhado Complexo — estado mutable com lógica complexa, acesso síncono, sem servidor. Zustand ou Redux Toolkit. Exemplos: carrinho de compras local, filtros globais, wizard multi-etapas.
A maior armadilha: colocar dados do servidor (produtos, usuários, pedidos) em Context API ou Zustand. Esses dados têm ciclo de vida próprio: precisam de loading state, error handling, revalidação, deduplificação de requests e invalidação de cache. React Query foi construído exatamente para isso. Use-o.
Context API: O Padrão Correto
A Context API é frequentemente mal usada porque parece simples: crie um contexto, envolva com um Provider, consuma com useContext. O problema surge quando o contexto muda frequentemente: todo componente que consome o contexto re-renderiza, mesmo que o dado que ele usa não tenha mudado. A solução é separar contextos por domínio (AuthContext, ThemeContext, ToastContext) e usar useMemo para estabilizar o valor do contexto quando ele depende de dados que mudam.
Context API é ideal para estado de UI global que muda raramente e é compartilhado por toda a árvore. A chave é criar contextos separados por domínio — não um único AppContext:
import {
createContext, useContext, useState, useCallback,
useMemo, type ReactNode
} from 'react';
interface User {
id: string;
name: string;
email: string;
avatar: string | null;
}
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
signIn: (accessToken: string, user: User) => void;
signOut: () => void;
updateUser: (data: Partial<User>) => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const signIn = useCallback((accessToken: string, userData: User) => {
// Access token salvo em memória (não em localStorage — XSS)
// Refresh token chega via HttpOnly Cookie do backend
api.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
setUser(userData);
}, []);
const signOut = useCallback(() => {
delete api.defaults.headers.common.Authorization;
setUser(null);
// O refresh token no HttpOnly Cookie é apagado pelo backend em /auth/logout
}, []);
const updateUser = useCallback((data: Partial<User>) => {
setUser((prev) => (prev ? { ...prev, ...data } : null));
}, []);
// useMemo: evita recriar o objeto value a cada render
// Sem isso, TODOS os consumidores re-renderizam em qualquer update do Provider
const value = useMemo(
() => ({
user,
isAuthenticated: !!user,
signIn,
signOut,
updateUser,
}),
[user, signIn, signOut, updateUser]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// Hook com guard — lança erro descritivo se usado fora do Provider
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth deve ser usado dentro de um <AuthProvider />');
}
return context;
}Context API com useReducer: Para Lógica Complexa
import {
createContext, useContext, useReducer, useCallback,
useMemo, type ReactNode
} from 'react';
interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
description?: string;
duration?: number;
}
type ToastAction =
| { type: 'ADD'; payload: Toast }
| { type: 'REMOVE'; payload: { id: string } };
function toastReducer(state: Array<Toast>, action: ToastAction): Array<Toast> {
switch (action.type) {
case 'ADD':
// Limita a 5 toasts simultâneos
return [...state.slice(-4), action.payload];
case 'REMOVE':
return state.filter((t) => t.id !== action.payload.id);
default:
return state;
}
}
interface ToastContextValue {
toasts: Array<Toast>;
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, []);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = crypto.randomUUID();
dispatch({ type: 'ADD', payload: { ...toast, id } });
// Auto-remove após a duração especificada
const duration = toast.duration ?? 5000;
setTimeout(() => dispatch({ type: 'REMOVE', payload: { id } }), duration);
}, []);
const removeToast = useCallback((id: string) => {
dispatch({ type: 'REMOVE', payload: { id } });
}, []);
const value = useMemo(
() => ({ toasts, addToast, removeToast }),
[toasts, addToast, removeToast]
);
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
}
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast deve ser usado dentro de <ToastProvider />');
return context;
}Zustand: Estado de Cliente Mais Complexo
Para estado de cliente mais complexo (carrinho, wizard, filtros persistentes), Zustand é mais ergonômico que Context API: acesso síncrono sem hooks, seleção granular de estado para evitar re-renders, e sem necessidade de Provider:
npm install zustandimport { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem {
productId: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: Array<CartItem>;
totalItems: number;
totalPrice: number;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clear: () => void;
}
export const useCartStore = create<CartStore>()(
// persist: salva automaticamente no localStorage
persist(
(set, get) => ({
items: [],
totalItems: 0,
totalPrice: 0,
addItem: (item) => {
const existing = get().items.find((i) => i.productId === item.productId);
if (existing) {
set((state) => ({
items: state.items.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + 1 }
: i
),
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + item.price,
}));
} else {
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + item.price,
}));
}
},
removeItem: (productId) => {
const item = get().items.find((i) => i.productId === productId);
if (!item) return;
set((state) => ({
items: state.items.filter((i) => i.productId !== productId),
totalItems: state.totalItems - item.quantity,
totalPrice: state.totalPrice - item.price * item.quantity,
}));
},
updateQuantity: (productId, quantity) => {
if (quantity <= 0) { get().removeItem(productId); return; }
set((state) => ({
items: state.items.map((i) =>
i.productId === productId ? { ...i, quantity } : i
),
}));
},
clear: () => set({ items: [], totalItems: 0, totalPrice: 0 }),
}),
{ name: 'cart-storage' } // Chave no localStorage
)
);
// Seleção granular: componente re-renderiza apenas quando totalItems muda
// const totalItems = useCartStore((state) => state.totalItems);
// const addItem = useCartStore((state) => state.addItem);Conclusão: A Regra de Ouro
A decisão de onde colocar o estado segue uma hierarquia: comece com useState local. Se precisar compartilhar com um vizinho, eleve (prop drilling por 1-2 níveis é aceitável). Se o dado vem do servidor, use React Query. Se é UI global que muda raramente, use Context API com useMemo. Se é estado de cliente complexo com atualizações frequentes, use Zustand. A complexidade cresce na ordem certa, e você não paga por aquilo que não usa.