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
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-workflowtype 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 estadoconst 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 DavidMapa de decisão (prático) para escolher a estratégia
// mapa de decisão para estratégia de estadoconst 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.

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