Voltar para Artigos
Front-end12 min de leitura

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.

12 de agosto de 2026

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:

AuthContext.tsx
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

ToastContext.tsx
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:

bash
npm install zustand
cartStore.ts
import { 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.