Entendendo Redux Toolkit: Fundamentos e Filosofia
Redux é uma biblioteca de gerenciamento de estado previsível para aplicações JavaScript. Porém, a configuração tradicional do Redux é verbose e exige muito código boilerplate. O Redux Toolkit (RTK) foi criado para simplificar drasticamente essa experiência, fornecendo utilitários que encapsulam as melhores práticas de forma elegante e moderna.
A filosofia do Redux Toolkit é simples: reduzir a complexidade sem perder o poder. Enquanto o Redux "puro" exigia que você criasse actions, action creators, reducers e middwares separadamente, o RTK consolida tudo isso em uma API intuitiva chamada Slices. Além disso, o toolkit já vem configurado com Redux Thunk (para ações assíncronas) e Immer (para mutações imutáveis de forma legível).
Slices: O Coração do Redux Toolkit
O que é um Slice?
Um Slice é um objeto que contém a lógica de reducer, actions e initial state tudo junto em um único lugar. É como ter um mini-domínio de estado com suas próprias regras e ações. Internamente, o createSlice utiliza Immer, permitindo que você escreva código que parece estar mutando o estado, mas na verdade mantém imutabilidade.
Criando seu Primeiro Slice
Vamos criar um exemplo prático de um slice para gerenciar um carrinho de compras:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
totalPrice: number;
}
const initialState: CartState = {
items: [],
totalPrice: 0,
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem: (state, action: PayloadAction<CartItem>) => {
const existingItem = state.items.find(
(item) => item.id === action.payload.id
);
if (existingItem) {
existingItem.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
state.totalPrice += action.payload.price * action.payload.quantity;
},
removeItem: (state, action: PayloadAction<string>) => {
const item = state.items.find((item) => item.id === action.payload);
if (item) {
state.totalPrice -= item.price * item.quantity;
state.items = state.items.filter((item) => item.id !== action.payload);
}
},
clearCart: (state) => {
state.items = [];
state.totalPrice = 0;
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
Observe como o código parece estar mutando state.items e state.totalPrice diretamente. Graças ao Immer, isso é seguro e resultará em um novo estado imutável. Não há necessidade de espalhamento (...) ou cópias manuais. As actions são geradas automaticamente, e o reducer está pronto para ser integrado.
Tipagem Robusta com TypeScript
Note que utilizei PayloadAction<CartItem> para tipar o payload das actions. Isso garante que TypeScript saiba exatamente qual tipo de dados está sendo passado. Se você tentar passar dados incompatíveis, o compilador avisará imediatamente. Essa tipagem rigorosa previne bugs em tempo de desenvolvimento, não apenas em runtime.
Thunks Tipados: Ações Assíncronas com Segurança
O Problema das Ações Assíncronas
Redux é síncrono por padrão: um reducer recebe uma action e retorna um novo estado. Mas aplicações reais precisam fazer requisições HTTP, consultar databases ou executar operações demoradas. Para isso, usamos Thunks — funções que retornam outras funções, permitindo lógica assíncrona antes de disparar uma action.
Criando um Thunk Tipado com createAsyncThunk
O createAsyncThunk do RTK fornece uma maneira tipada e inteligente de lidar com ações assíncronas. Ele automaticamente cria actions para pending, fulfilled e rejected:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
user: null,
loading: false,
error: null,
};
// Thunk tipado: recebe um userId como argumento
export const fetchUser = createAsyncThunk<
User, // Tipo de retorno (fulfilled)
string, // Tipo do argumento
{
rejectValue: string; // Tipo do erro
}
>(
'user/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
return rejectWithValue('Falha ao buscar usuário');
}
return await response.json();
} catch (error) {
return rejectWithValue('Erro de rede');
}
}
);
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload; // payload é tipado como User
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? 'Erro desconhecido'; // payload é tipado como string
});
},
});
export default userSlice.reducer;
Explicação dos generics: O primeiro genérico (User) define o tipo do action.payload em fulfilled. O segundo (string) define o tipo do argumento que o thunk recebe. No objeto de configuração, rejectValue especifica o tipo do erro retornado. TypeScript agora garantirá que você não cometa erros ao usar esses tipos.
RTK Query: Gerenciamento de Cache de Dados Remotos
Por que RTK Query?
Quando você tem múltiplas requisições HTTP, o padrão thunks + slices começa a ficar repetitivo: você escreve pending/fulfilled/rejected para cada requisição, gerencia cache manualmente, e lida com sincronização de estado. O RTK Query é uma solução de alto nível que abstrai toda essa complexidade, focando em APIs e cache automático.
RTK Query é particularmente poderoso porque oferece:
- Cache automático com estratégias de invalidação
- Requisições dedupliquées (mesma requisição não é feita duas vezes no mesmo tempo)
- Refetch automático em condições específicas
- Serialização de argumentos inteligente
- Suporte completo a WebSockets com
streamingUpdates
Criando uma API com RTK Query
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Product {
id: string;
name: string;
price: number;
description: string;
}
interface ApiResponse<T> {
data: T;
success: boolean;
}
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com',
}),
tagTypes: ['Product'],
endpoints: (builder) => ({
// Query: para leitura de dados
getProducts: builder.query<Product[], void>({
query: () => '/products',
providesTags: ['Product'],
}),
// Query parametrizada: para leitura com filtros
getProductById: builder.query<Product, string>({
query: (id) => `/products/${id}`,
providesTags: (result, error, id) => [{ type: 'Product', id }],
}),
// Mutation: para escrita/atualização de dados
createProduct: builder.mutation<Product, Omit<Product, 'id'>>({
query: (newProduct) => ({
url: '/products',
method: 'POST',
body: newProduct,
}),
invalidatesTags: ['Product'],
}),
// Mutation para atualização
updateProduct: builder.mutation<
Product,
{ id: string; data: Partial<Product> }
>({
query: ({ id, data }) => ({
url: `/products/${id}`,
method: 'PATCH',
body: data,
}),
invalidatesTags: (result, error, { id }) => [
{ type: 'Product', id },
'Product',
],
}),
// Mutation para deleção
deleteProduct: builder.mutation<void, string>({
query: (id) => ({
url: `/products/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Product'],
}),
}),
});
export const {
useGetProductsQuery,
useGetProductByIdQuery,
useCreateProductMutation,
useUpdateProductMutation,
useDeleteProductMutation,
} = productsApi;
Usando RTK Query em Componentes React
import React from 'react';
import {
useGetProductsQuery,
useCreateProductMutation,
} from './productsApi';
export const ProductList: React.FC = () => {
const { data: products, isLoading, error } = useGetProductsQuery();
const [createProduct, { isLoading: isCreating }] =
useCreateProductMutation();
if (isLoading) return <div>Carregando...</div>;
if (error) return <div>Erro ao carregar produtos</div>;
const handleAddProduct = async () => {
try {
await createProduct({
name: 'Novo Produto',
price: 99.99,
description: 'Um produto incrível',
}).unwrap(); // unwrap() torna a promise rejeitável
alert('Produto criado com sucesso');
} catch (err) {
alert('Falha ao criar produto');
}
};
return (
<div>
<button onClick={handleAddProduct} disabled={isCreating}>
{isCreating ? 'Criando...' : 'Adicionar Produto'}
</button>
<ul>
{products?.map((product) => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
</div>
);
};
RTK Query cuida de tudo: requisição, cache, atualização automática do cache quando você dispara uma mutation. Não há necessidade de escrever reducers extras ou gerenciar flags de loading manualmente (embora você tenha acesso a elas se precisar).
Integrando Tudo: Store Completo e Tipado
Configurando a Store com TypeScript
Agora que você entende Slices, Thunks e RTK Query, é hora de montar uma store funcional e totalmente tipada:
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './slices/cartSlice';
import userReducer from './slices/userSlice';
import { productsApi } from './apis/productsApi';
export const store = configureStore({
reducer: {
cart: cartReducer,
user: userReducer,
[productsApi.reducerPath]: productsApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(productsApi.middleware),
});
// Tipos exportados para uso em todo o app
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Hooks Tipados para Uso em Componentes
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Use esses hooks em vez dos imports diretos
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T,>(selector: (state: RootState) => T): T =>
useSelector(selector);
Com esses hooks, TypeScript saberá exatamente qual é o tipo de cada slice e garantirá que você não tente acessar propriedades inexistentes. Exemplo de uso:
import { useAppSelector, useAppDispatch } from './hooks';
import { fetchUser } from './slices/userSlice';
import { addItem } from './slices/cartSlice';
export const MyComponent: React.FC = () => {
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.user.user); // Tipado automaticamente
const cartItems = useAppSelector((state) => state.cart.items); // Tipado automaticamente
const handleAddToCart = () => {
dispatch(
addItem({
id: '1',
name: 'Produto',
price: 100,
quantity: 1,
})
);
};
const handleLoadUser = () => {
dispatch(fetchUser('123')); // Tipado como string
};
return (
<div>
{/* Seu componente aqui */}
</div>
);
};
Conclusão
Aprendemos três pilares fundamentais do Redux Toolkit moderno: Slices simplificam drasticamente o boilerplate reduzindo actions, reducers e initial state em uma única declaração; Thunks tipados permitem operações assíncronas com segurança de tipo, onde pending, fulfilled e rejected são gerenciados automaticamente; RTK Query abstrai o gerenciamento de cache HTTP, eliminando código repetitivo e oferecendo recursos sofisticados como deduplicação e invalidação automática de tags. Juntos, esses três conceitos formam uma stack completa e robusta para gerenciamento de estado em aplicações modernas com TypeScript.