Como Usar Redux Toolkit Moderno: Slices, RTK Query e Thunks Tipados em Produção Já leu

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.

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.

Referências


Artigos relacionados