Todos os posts
Arquiteturaquarta-feira, 15 de outubro de 2025

Gerenciamento de Estado em React: Guia Completo (sem overengineering)

Um framework de decisão para escolher estado local, reducers, Context, stores com selectors e ferramentas de server-state (TanStack Query/RTK Query) — com foco em clareza, performance e escalabilidade.

Leonardo David

Leonardo David

24 min de leitura

Problemas de estado raramente começam pela biblioteca. O maior erro é misturar estado de UI, estado de servidor, estado de URL e regras de negócio sob a mesma abstração — isso vira acoplamento e bugs difíceis de explicar.

Antes de escolher uma biblioteca, classifique o estado por natureza e ciclo de vida. Em apps modernas, você quase sempre tem pelo menos: (1) UI state local, (2) server state (cache de dados remotos), (3) URL state, (4) form state e (5) client cache / workflow compartilhado.

A taxonomia prática de estado (o que vai pra onde)

// taxonomia de estado em React (mapa mental)
type StateKind =
    | "local_ui"      // toggle, open/close, input simples, hover, step local
    | "screen_logic"  // lógica de tela com múltiplas transições (wizard, filtros complexos)
    | "server_state"  // dados remotos com cache, invalidação, retry, optimistic update
    | "url_state"     // filtros/página/sort no querystring (deep-link e share)
    | "form_state"    // validação, touched/dirty, submit, errors
    | "app_session"   // sessão, permissões, tema, locale
    | "shared_workflow"; // carrinho, draft global, seleção cross-screen, etc.

1) Priorize estado local até existir necessidade real de compartilhamento

Elevar estado cedo demais aumenta acoplamento e deixa testes frágeis. Regra simples: se apenas um componente (ou uma sub-árvore pequena) precisa do estado, mantenha local. Se a tela ficou “transacional” (muitas transições e efeitos), suba para useReducer para organizar a lógica.

// useState vs useReducer: quando a tela vira um mini-workflow
type Action =
    | { type: "OPEN" }
    | { type: "CLOSE" }
    | { type: "SET_FILTER"; value: string };
  
  type ScreenState = { isOpen: boolean; filter: string };
  
  function reducer(state: ScreenState, action: Action): ScreenState {
    switch (action.type) {
      case "OPEN": return { ...state, isOpen: true };
      case "CLOSE": return { ...state, isOpen: false };
      case "SET_FILTER": return { ...state, filter: action.value };
      default: return state;
    }
  }

2) Context distribui dependência; não substitui toda estratégia de estado

Context é excelente para dependências relativamente estáveis (sessão, tema, config). Para atualizações frequentes, ele tende a “espalhar rerender” se você colocar muito estado mutável dentro dele. Um padrão sólido é: reducer + context para uma tela/fluxo, e store com selectors quando o compartilhamento é amplo e frequente.

O próprio React recomenda combinar reducer e context para escalar estado de uma tela complexa com dispatch distribuído, mantendo a lógica centralizada no reducer.

3) Server state: trate como cache, não como 'state global'

Dados remotos (listas, detalhe, permissões vindas do backend) são outra categoria: você quer cache, invalidação, retry, dedupe, background refetch e optimistic updates. Isso é o trabalho de ferramentas de server-state como TanStack Query — e não de um store genérico.

Em TanStack Query, por padrão, queries são consideradas “stale” (isso surpreende gente nova). Você controla o tradeoff com staleTime e políticas globais. Invalidação marca como stale e pode disparar refetch em background.

// TanStack Query: defaults, staleTime e invalidação (padrão de times seniors)
// exemplo de padrão: defina defaults consistentes no QueryClient
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30_000, // 30s 'fresh' para evitar refetch agressivo
        gcTime: 5 * 60_000 // ajuste conforme app (padrão é 5 min)
      }
    }
  });
  
  // após uma mutation, invalide a família de queries afetadas
  await queryClient.invalidateQueries({ queryKey: ["users"] });

RTK Query: quando você já está no ecossistema Redux

Se seu app já usa Redux Toolkit para workflow compartilhado e você quer um layer consistente para data fetching/caching, RTK Query é o caminho natural: ele existe exatamente para eliminar boilerplate de fetch + cache no Redux.

// RTK Query: separa server-state (API slice) do resto do store
// ideia: server-state fica no api slice (cache + invalidation)
  const api = createApi({
    reducerPath: "api",
    baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
    tagTypes: ["User"],
    endpoints: (builder) => ({
      getUsers: builder.query<User[], void>({
        query: () => "users",
        providesTags: ["User"],
      }),
      updateUser: builder.mutation<User, Partial<User>>({
        query: (body) => ({ url: `users/${body.id}`, method: "PATCH", body }),
        invalidatesTags: ["User"],
      }),
    }),
  });

4) Store dedicada: quando o estado é compartilhado, frequente e client-side

Uma store (Zustand/Redux) faz sentido quando você tem: (a) estado compartilhado entre rotas, (b) atualizações frequentes, (c) necessidade de APIs claras de leitura/escrita e (d) regras de negócio client-side (ex: carrinho, draft, seleção global).

Em Zustand, a disciplina vem de selectors: o componente deve assinar apenas o que usa. Isso reduz rerenders e deixa dependências explícitas.

// Zustand: selectors + actions (padrão de fronteira clara)
type CartItem = { id: string; qty: number };
  
  type CartState = {
    items: CartItem[];
    add: (id: string) => void;
    remove: (id: string) => void;
  };
  
  export const useCartStore = create<CartState>()((set, get) => ({
    items: [],
    add: (id) =>
      set((s) => {
        const existing = s.items.find((i) => i.id === id);
        return {
          items: existing
            ? s.items.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i))
            : [...s.items, { id, qty: 1 }],
        };
      }),
    remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
  }));
  
  // consumo com selector: assina só o necessário
  const qty = useCartStore((s) => s.items.length);
  const add = useCartStore((s) => s.add);

5) URL state: o estado que você quer compartilhar via link

Filtros, paginação, ordenação e busca geralmente deveriam morar na URL (querystring) quando: você quer deep-link, back/forward consistente e compartilhamento via link. Isso reduz necessidade de store global só para 'filtro da tela'.

Anti-patterns que custam caro (e como evitar)

1) Guardar dados do servidor em store genérica sem política de cache/invalidação. 2) Context com objetos enormes mudando toda hora. 3) Misturar estado derivado (computed) como fonte de verdade. 4) Expor setters genéricos sem invariantes (qualquer um muda qualquer coisa).

// checklist rápido de arquitetura de estado
const checklist = [
    "Cada estado tem um 'dono' claro?",
    "Server state tem cache + invalidation (não store genérica)?",
    "UI state fica local por padrão?",
    "URL state está na URL quando precisa ser compartilhável?",
    "Store tem selectors e API de ações (não setters soltos)?",
    "Existe regra de derivação (computed) fora da fonte de verdade?",
    "O que acontece em erro/retry/offline/optimistic update?",
  ];

“A melhor arquitetura de estado minimiza mutabilidade compartilhada e maximiza clareza de decisão: cada categoria de estado tem uma ferramenta e uma fronteira.”

— Leonardo David

Mapa de decisão (prático) para escolher a estratégia

// mapa de decisão para estratégia de estado
const stateStrategy = {
    localUI: "useState (simples) / useReducer (workflow de tela)",
    screenScopedShared: "Reducer + Context (escopo de uma feature/rota)",
    crossScreenWorkflow: "Store com selectors (Zustand ou Redux Toolkit)",
    serverData: "TanStack Query (ou RTK Query se já usa Redux)",
    urlState: "Querystring / router (deep-link)",
    appSessionConfig: "React Context (dados estáveis) + boundaries claras",
    formState: "React Hook Form / Form libs (evite reinventar)",
  };

Se você aplicar essa separação, a escala vem como consequência: menos rerenders acidentais, menos estado duplicado, invalidação previsível e um modelo mental que o time inteiro consegue explicar.

ReactStateEscalabilidadeTanStack QueryRedux ToolkitZustand
Leonardo David

Escrito por Leonardo David

Engenheiro Full Stack. Atuo construindo produtos de alta performance na intersecção entre frontend, backend e estratégia de produto.