Como usar React com Redux: Guia Completo
Introdução
Gerenciar estado em aplicações React de médio e grande porte é um dos desafios mais recorrentes no dia a dia de quem trabalha com front-end. Conforme a aplicação cresce, o estado compartilhado entre componentes distantes na árvore de renderização se torna difícil de rastrear, debugar e manter. É exatamente nesse cenário que o Redux entra como solução robusta e previsível.
Este guia foi escrito para desenvolvedores com pelo menos dois anos de experiência em React que desejam entender como integrar Redux de forma moderna, utilizando Redux Toolkit (a abordagem oficial e recomendada desde 2019) em vez do Redux "clássico" com boilerplate extenso. Vamos cobrir desde a instalação até padrões avançados, passando por exemplos práticos com TypeScript, boas práticas de organização e os erros mais comuns que encontro em projetos reais.
Se você já trabalha com Context API e está sentindo limitações de performance ou organização, este é o momento certo para considerar Redux. Vamos ao que interessa.
Por Que Usar React com Redux
O Problema do Estado Compartilhado
O React oferece mecanismos nativos para gerenciar estado: useState, useReducer e Context API. Para aplicações pequenas, isso é suficiente. Porém, quando você tem dezenas de componentes que dependem do mesmo estado global (autenticação, carrinho de compras, preferências do usuário, cache de dados de API), surgem problemas concretos:
- Prop drilling excessivo: passar dados por cinco ou seis níveis de componentes intermediários que não usam esses dados.
- Re-renderizações desnecessárias: a Context API causa re-renderização de todos os consumidores quando qualquer parte do contexto muda.
- Falta de previsibilidade: sem um padrão claro de mutação de estado, debugar se torna uma tarefa arqueológica.
Onde Redux Brilha
Redux resolve esses problemas com um modelo baseado em três princípios: estado único e centralizado, estado somente leitura (imutável) e mudanças feitas por funções puras (reducers). Na prática, os casos de uso onde essa combinação se destaca são:
- E-commerce: carrinho de compras, filtros de busca, estado de checkout com múltiplas etapas.
- Dashboards corporativos: dados vindos de múltiplas APIs que precisam ser combinados e compartilhados entre painéis.
- Aplicações com fluxos complexos de autenticação e autorização: controle de roles, tokens, refresh tokens e estado de sessão.
- Aplicações offline-first: onde o estado local precisa ser sincronizado com o servidor de forma controlada.
Abordagem Moderna vs. Legada
É importante entender a evolução do ecossistema. O Redux "clássico" (com createStore, action types como constantes de string, switch-case em reducers e middleware manual) é considerado legado. A abordagem moderna utiliza Redux Toolkit (RTK), que elimina a maior parte do boilerplate e inclui ferramentas como createSlice, createAsyncThunk e RTK Query para gerenciamento de dados do servidor.
Se você está começando um projeto novo, use Redux Toolkit. Se está mantendo um projeto legado com Redux clássico, considere migrar gradualmente: o RTK é retrocompatível.
Setup Completo do Projeto
Instalação e Configuração Inicial
Vamos criar um projeto do zero com Vite, React, TypeScript e Redux Toolkit.
// Comandos no terminal:
// npm create vite@latest meu-app -- --template react-ts
// cd meu-app
// npm install @reduxjs/toolkit react-redux
// npm install -D @types/react-reduxApós a instalação, a estrutura de pastas recomendada para o Redux segue o padrão "feature-based":
src/
├── app/
│ ├── store.ts
│ └── hooks.ts
├── features/
│ ├── auth/
│ │ ├── authSlice.ts
│ │ └── AuthForm.tsx
│ └── products/
│ ├── productsApi.ts
│ └── ProductList.tsx
├── App.tsx
└── main.tsxHooks Tipados Reutilizáveis
Em vez de usar useSelector e useDispatch diretamente (que exigem tipagem manual em cada chamada), crie hooks tipados uma única vez:
// src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();Conectando a Store ao React
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);Exemplo Prático: Sistema de Autenticação e Lista de Produtos
Slice de Autenticação
Vamos implementar um slice completo de autenticação com login assíncrono. Esse é um cenário real que você encontrará em praticamente qualquer aplicação.
// src/features/auth/authSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
interface AuthState {
user: User | null;
token: string | null;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
const initialState: AuthState = {
user: null,
token: null,
status: 'idle',
error: null,
};
interface LoginCredentials {
email: string;
password: string;
}
interface LoginResponse {
user: User;
token: string;
}
// Thunk assíncrono para login
export const loginUser = createAsyncThunk<
LoginResponse,
LoginCredentials,
{ rejectValue: string }
>('auth/loginUser', async (credentials, { rejectWithValue }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData.message ?? 'Erro ao fazer login');
}
return (await response.json()) as LoginResponse;
} catch (error) {
return rejectWithValue('Erro de conexão com o servidor');
}
});
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
logout(state) {
state.user = null;
state.token = null;
state.status = 'idle';
state.error = null;
},
clearAuthError(state) {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(loginUser.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload ?? 'Erro desconhecido';
});
},
});
export const { logout, clearAuthError } = authSlice.actions;
export default authSlice.reducer;Note que dentro do createSlice você pode "mutar" o estado diretamente. Isso é seguro porque o Redux Toolkit usa Immer internamente, que converte essas mutações em atualizações imutáveis.
Slice de Produtos com RTK Query
Para busca de dados do servidor, a abordagem mais moderna é usar RTK Query, que elimina a necessidade de gerenciar manualmente estados de loading, cache e invalidação:
// src/features/products/productsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Product {
id: string;
name: string;
price: number;
category: string;
}
interface ProductsResponse {
products: Product[];
total: number;
}
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
// Adiciona token de autenticação automaticamente
const token = (getState() as { auth: { token: string | null } }).auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ['Products'],
endpoints: (builder) => ({
getProducts: builder.query<ProductsResponse, { page: number; category?: string }>({
query: ({ page, category }) => ({
url: '/products',
params: { page, limit: 20, category },
}),
providesTags: ['Products'],
}),
createProduct: builder.mutation<Product, Omit<Product, 'id'>>({
query: (newProduct) => ({
url: '/products',
method: 'POST',
body: newProduct,
}),
invalidatesTags: ['Products'],
}),
deleteProduct: builder.mutation<void, string>({
query: (id) => ({
url: `/products/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Products'],
}),
}),
});
export const {
useGetProductsQuery,
useCreateProductMutation,
useDeleteProductMutation,
} = productsApi;Configurando a Store com TypeScript
O primeiro passo é criar a store central. Com Redux Toolkit, isso é feito com configureStore, que já inclui o middleware padrão (thunk e serializable check) e habilita o Redux DevTools automaticamente em desenvolvimento.
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';
import { productsApi } from '../features/products/productsApi';
export const store = configureStore({
reducer: {
auth: authReducer,
[productsApi.reducerPath]: productsApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(productsApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;Componente que Consome a Store
// src/features/products/ProductList.tsx
import React, { useState } from 'react';
import { useAppSelector } from '../../app/hooks';
import {
useGetProductsQuery,
useDeleteProductMutation,
} from './productsApi';
const ProductList: React.FC = () => {
const [page, setPage] = useState(1);
const user = useAppSelector((state) => state.auth.user);
const {
data,
isLoading,
isError,
error,
refetch,
} = useGetProductsQuery({ page });
const [deleteProduct, { isLoading: isDeleting }] = useDeleteProductMutation();
if (isLoading) return <p>Carregando produtos...</p>;
if (isError) return <p>Erro ao carregar: {JSON.stringify(error)}</p>;
const handleDelete = async (id: string) => {
try {
await deleteProduct(id).unwrap();
} catch (err) {
console.error('Falha ao deletar produto:', err);
}
};
return (
<div>
<h2>Produtos ({data?.total ?? 0})</h2>
<ul>
{data?.products.map((product) => (
<li key={product.id}>
{product.name}: R$ {product.price.toFixed(2)}
{user?.role === 'admin' && (
<button
onClick={() => handleDelete(product.id)}
disabled={isDeleting}
>
Remover
</button>
)}
</li>
))}
</ul>
<button onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</button>
<span> Página {page} </span>
<button onClick={() => setPage((p) => p + 1)}>
Próxima
</button>
<button onClick={refetch}>Atualizar</button>
</div>
);
};
export default ProductList;Boas Práticas para React com Redux
Organize por Feature, Não por Tipo
Evite criar pastas como actions/, reducers/, constants/. Agrupe tudo que pertence a uma feature no mesmo diretório. Isso facilita a navegação e reduz o acoplamento entre módulos.
Use Seletores Memoizados
Os produtos deste exemplo estão no cache de RTK Query. Para ler uma consulta, use productsApi.endpoints.getProducts.select com os mesmos argumentos de página e categoria usados na busca. O resultado contém data.products e data.total. O seletor lê o cache; ele não inicia uma requisição. A filtragem abaixo considera somente a página consultada, não todos os produtos do catálogo.
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';
import { productsApi } from './productsApi';
export function makeSelectExpensiveProducts(args: {
page: number;
category?: string;
}) {
const selectQuery = productsApi.endpoints.getProducts.select(args);
const selectAllProducts = (state: RootState) =>
selectQuery(state).data?.products;
return createSelector([selectAllProducts], (items) =>
(items ?? []).filter((item) => item.price > 100),
);
}
// Exemplo fixo: somente os produtos em cache da página 1, sem categoria.
export const selectExpensiveProducts = makeSelectExpensiveProducts({ page: 1 });Mantenha o Menor Privilégio na Lógica de Autorização
Ao implementar controle de acesso no front-end com base em roles armazenadas no Redux, aplique o princípio de menor privilégio: cada componente deve verificar apenas as permissões mínimas necessárias para sua funcionalidade. Nunca exponha ações administrativas para usuários comuns baseando-se apenas no estado do cliente. A validação de permissões deve sempre ser reforçada no backend.
Normalize Dados Complexos
Para listas grandes de entidades com relacionamentos, use createEntityAdapter do RTK. Ele fornece operações CRUD prontas e mantém os dados em formato normalizado (um objeto de IDs mapeando para entidades), o que melhora drasticamente a performance de atualizações.
Não Coloque Tudo no Redux
Nem todo estado precisa ir para a store global. Estado de formulário (use React Hook Form ou similar), estado de UI local (modal aberto/fechado, hover) e estado derivado de props devem permanecer no componente. Use Redux para estado verdadeiramente compartilhado entre features.
Erros Comuns e Soluções
1. Mutação direta do estado fora do createSlice
"A]case reducer returned a new state value AND modified the previous state": isso acontece quando você retorna um novo objeto E modifica o state dentro do mesmo reducer. Solução: ou mute diretamente o draft (sem return) ou retorne um novo objeto (sem mutar o draft). Nunca faça os dois.
2. Serialização de valores não serializáveis
"A non-serializable value was detected in the state": você está armazenando instâncias de Date, Map, Set, funções ou classes na store. Solução: converta Dates para strings ISO antes de armazenar (new Date().toISOString()) e reconstrua no componente quando necessário.
3. Seletores sem memoização causando re-renderizações
Re-renderizações excessivas sem mudança real de dados: quando o seletor cria um novo array ou objeto em cada chamada (state.items.filter(...) inline no useSelector), o React interpreta como dados novos. Solução: mova a lógica de filtragem para um seletor criado com createSelector, que só recalcula quando as dependências mudam.
4. Esquecer de adicionar o middleware do RTK Query
"No query found for endpoint X" ou mutations que simplesmente não funcionam: isso ocorre quando o middleware do RTK Query não foi adicionado na configuração da store. Solução: certifique-se de incluir .concat(api.middleware) na configuração de middleware do configureStore, conforme mostrado na seção de setup deste guia.
5. Acessar estado antes da inicialização
"Cannot read properties of undefined (reading 'user')": o componente tenta acessar state.auth.user mas o reducer de auth não foi registrado ou o Provider não envolve o componente. Solução: verifique se todos os reducers estão no configureStore e se o <Provider store={store}> está no nível mais alto da árvore de componentes (geralmente em main.tsx).
6. Usar createAsyncThunk sem tratar rejeição
Erros silenciosos em chamadas assíncronas: quando o thunk rejeita mas nenhum case de rejected foi definido no extraReducers, o erro desaparece sem feedback ao usuário. Solução: sempre adicione handlers para os três estados (pending, fulfilled, rejected) e exiba mensagens de erro na interface.
7. Cleanup: remover o Redux de forma segura
Se em algum momento você precisar remover o Redux do projeto (migração para outra solução ou simplificação), o processo de cleanup é:
// No terminal:
// npm uninstall @reduxjs/toolkit react-redux @types/react-redux
// Depois remova manualmente:
// 1. O diretório src/app/ (store.ts e hooks.ts)
// 2. O Provider no main.tsx
// 3. Todas as referências a useAppSelector, useAppDispatch e slicesQuando Considerar Alternativas
Redux não é a única opção. Para projetos menores, Zustand oferece uma API mais simples com menos boilerplate. Para gerenciamento de estado do servidor (fetch, cache, sincronização), TanStack Query (React Query) pode ser suficiente sem Redux. Porém, quando você precisa de estado global complexo no cliente com ações previsíveis e ferramentas de debug poderosas (Redux DevTools com time-travel), Redux Toolkit continua sendo a escolha mais sólida do ecossistema React.
Você pode aprofundar o gerenciamento de dados do servidor em nosso guia sobre Server Actions, que aborda uma abordagem complementar usando Next.js 15: Revolucione sua Aplicação React com Next.js 15 Server Actions. Também vale explorar em outro guia como combinar Redux Toolkit com React Hook Form para cenários de formulários complexos que dependem de estado global.
Conclusão
Redux Toolkit reduz o código repetitivo de configuração, enquanto RTK Query gerencia o cache das consultas ao servidor. Neste exemplo, autenticação e produtos usam essas duas partes do ecossistema com hooks tipados e uma store consistente.
O caminho que percorremos neste guia (setup com TypeScript, slices organizados por feature, hooks tipados, RTK Query para dados do servidor) representa o padrão recomendado pela própria equipe do Redux. Comece implementando em uma feature isolada do seu projeto, valide os benefícios e expanda gradualmente. O investimento inicial de configuração se paga rapidamente em manutenibilidade e clareza do código.
Guias Relacionados
Explore mais guias técnicos
Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.
Ver todos os guias