Tipando reducers e actions sem Redux Toolkit

1. Por que tipar manualmente reducers e actions?

Quando trabalhamos com gerenciamento de estado em TypeScript, a tipagem forte oferece benefícios significativos: autocompletar no editor, detecção de erros em tempo de compilação e documentação viva do código. O Redux Toolkit simplifica esse processo com createSlice, que gera automaticamente tipos para actions e reducers. No entanto, existem situações onde optar pela tipagem manual é vantajoso:

  • Controle fino sobre tipos específicos: você define exatamente como cada action se comporta
  • Projetos legados: migração gradual de JavaScript para TypeScript sem dependências adicionais
  • Aprendizado profundo: entender como o TypeScript lida com discriminated unions e narrowing
  • Ambientes restritos: projetos que não podem adicionar o Redux Toolkit como dependência

2. Definindo tipos para actions com union types

A base da tipagem manual de actions é criar um tipo união que represente todas as actions possíveis. Usamos type em vez de interface porque precisamos de discriminated unions:

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET' };

A escolha entre type e interface é importante aqui: type permite criar uniões de objetos literais, enquanto interface não suporta essa sintaxe diretamente.

3. Adicionando payloads com generics e discriminated unions

Para actions que carregam dados, adicionamos propriedades específicas mantendo o type como discriminador:

type Action =
  | { type: 'ADD_TODO'; payload: { id: string; text: string; completed: boolean } }
  | { type: 'TOGGLE_TODO'; payload: { id: string } }
  | { type: 'REMOVE_TODO'; payload: { id: string } }
  | { type: 'CLEAR_COMPLETED' };

O TypeScript usa o campo type como discriminador, permitindo narrowing preciso dentro do reducer.

4. Tipando o reducer com TypeScript

A assinatura completa do reducer é tipada com o estado e a união de actions:

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface State {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
}

function todoReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, action.payload]
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id 
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    case 'REMOVE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload.id)
      };
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter(todo => !todo.completed)
      };
    default:
      return state;
  }
}

Para garantir exaustividade, use o tipo never no default:

function todoReducer(state: State, action: Action): State {
  switch (action.type) {
    // ...cases
    default:
      const _exhaustiveCheck: never = action;
      return state;
  }
}

Se uma nova action for adicionada à união Action mas não tratada no switch, o TypeScript apontará erro de compilação.

5. Criando action creators tipados

Action creators são funções que retornam o tipo exato da action. Use as const para inferência automática:

import { v4 as uuidv4 } from 'uuid';

const addTodo = (text: string) => ({
  type: 'ADD_TODO' as const,
  payload: {
    id: uuidv4(),
    text,
    completed: false
  }
});

const toggleTodo = (id: string) => ({
  type: 'TOGGLE_TODO' as const,
  payload: { id }
});

const removeTodo = (id: string) => ({
  type: 'REMOVE_TODO' as const,
  payload: { id }
});

const clearCompleted = () => ({
  type: 'CLEAR_COMPLETED' as const
});

Com as const, o TypeScript infere o tipo literal exato para type, permitindo que o discriminador funcione corretamente.

6. Lidando com estado complexo e actions assíncronas

Para requisições assíncronas, criamos actions para cada estado da requisição:

interface User {
  id: number;
  name: string;
  email: string;
}

interface AsyncState {
  users: User[];
  loading: boolean;
  error: string | null;
}

type AsyncAction =
  | { type: 'FETCH_USERS_START' }
  | { type: 'FETCH_USERS_SUCCESS'; payload: User[] }
  | { type: 'FETCH_USERS_ERROR'; payload: string }
  | { type: 'ADD_USER'; payload: User }
  | { type: 'REMOVE_USER'; payload: { id: number } };

function asyncReducer(state: AsyncState, action: AsyncAction): AsyncState {
  switch (action.type) {
    case 'FETCH_USERS_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_USERS_SUCCESS':
      return { ...state, loading: false, users: action.payload };
    case 'FETCH_USERS_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'ADD_USER':
      return { ...state, users: [...state.users, action.payload] };
    case 'REMOVE_USER':
      return {
        ...state,
        users: state.users.filter(user => user.id !== action.payload.id)
      };
    default:
      return state;
  }
}

Para estado aninhado, podemos usar sub-reducers tipados:

interface AppState {
  auth: { user: User | null; token: string | null };
  todos: Todo[];
  ui: { theme: 'light' | 'dark'; sidebar: boolean };
}

type AppAction = AuthAction | TodoAction | UIAction;

7. Boas práticas e patterns avançados

Extraindo tipos de action com Extract

type AddTodoAction = Extract<Action, { type: 'ADD_TODO' }>;
// { type: 'ADD_TODO'; payload: { id: string; text: string; completed: boolean } }

Criando um tipo genérico Reducer

type Reducer<S, A> = (state: S, action: A) => S;

const createReducer = <S, A extends { type: string }>(
  initialState: S,
  handlers: { [K in A['type']]?: (state: S, action: Extract<A, { type: K }>) => S }
): Reducer<S, A> => {
  return (state = initialState, action: A) => {
    const handler = handlers[action.type];
    return handler ? handler(state, action as any) : state;
  };
};

Testando a tipagem

Use o TypeScript Playground ou testes unitários para verificar a segurança de tipos:

// Teste de compilação
const testReducer = createReducer<State, Action>(initialState, {
  'ADD_TODO': (state, action) => {
    // action.payload tem tipo { id: string; text: string; completed: boolean }
    return { ...state, todos: [...state.todos, action.payload] };
  }
});

Dicas para manutenção em projetos grandes

  1. Separe actions por domínio: crie arquivos como authActions.ts, todoActions.ts
  2. Centralize tipos de estado: use um arquivo types.ts para definir interfaces de estado e uniões de action
  3. Use enums para tipos de action: facilita a manutenção e evita erros de digitação
  4. Documente actions complexas: adicione comentários JSDoc para payloads não óbvios
export enum TodoActionType {
  ADD = 'ADD_TODO',
  TOGGLE = 'TOGGLE_TODO',
  REMOVE = 'REMOVE_TODO',
  CLEAR_COMPLETED = 'CLEAR_COMPLETED'
}

type TodoAction =
  | { type: TodoActionType.ADD; payload: { id: string; text: string } }
  | { type: TodoActionType.TOGGLE; payload: { id: string } }
  | { type: TodoActionType.REMOVE; payload: { id: string } }
  | { type: TodoActionType.CLEAR_COMPLETED };

A tipagem manual de reducers e actions em TypeScript oferece controle granular e aprofundamento técnico. Embora o Redux Toolkit simplifique o processo, entender os fundamentos permite criar soluções mais flexíveis e adaptadas a necessidades específicas.

Referências