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.