useReducer: estado complexo sem Redux

1. Introdução ao useReducer: Quando e Por Que Usar

Gerenciar estado em aplicações React pode se tornar desafiador à medida que a complexidade cresce. Enquanto useState é perfeito para valores simples como strings ou booleanos, ele rapidamente se mostra limitado quando lidamos com objetos aninhados, múltiplos sub-valores ou lógicas de transição complexas.

useReducer surge como uma alternativa elegante ao useState e ao Redux para cenários de complexidade intermediária. Sua principal diferença está na separação clara entre estado e lógica de atualização: enquanto useState atualiza o estado diretamente, useReducer delega essa responsabilidade a uma função pura chamada reducer.

Cenários ideais para useReducer:
- Estado com múltiplos campos interdependentes (formulários, carrinhos de compras)
- Lógica de transição complexa (máquinas de estado, jogos)
- Atualizações que dependem do estado anterior
- Quando você precisa de previsibilidade e testabilidade

Vantagens sobre Redux em projetos menores:
- Sem dependência externa (faz parte do core do React)
- Menos boilerplate: sem providers globais, middlewares ou action creators complexos
- Código mais enxuto e fácil de entender para equipes pequenas

2. Estrutura Básica do useReducer

A anatomia do hook é simples e direta:

const [state, dispatch] = useReducer(reducer, initialState);

O initialState geralmente é definido como um objeto ou array. O reducer é uma função pura que recebe o estado atual e uma ação, retornando o novo estado:

const initialState = { count: 0, step: 1 };

function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step };
    case 'DECREMENT':
      return { ...state, count: state.count - state.step };
    case 'SET_STEP':
      return { ...state, step: action.payload };
    default:
      return state;
  }
}

3. Actions e Dispatch: Ações Disparando Mudanças

As ações seguem um padrão previsível: um objeto com pelo menos a propriedade type (string descritiva) e, opcionalmente, um payload com dados adicionais:

// Disparando ações
dispatch({ type: 'INCREMENT' });
dispatch({ type: 'SET_STEP', payload: 5 });

A função dispatch garante que o estado seja atualizado de forma previsível, evitando mutações diretas e facilitando o debug.

4. Trabalhando com Estado Complexo: Objetos e Arrays Aninhados

Vamos implementar um gerenciador de carrinho de compras com múltiplos itens:

const initialCart = {
  items: [],
  total: 0
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const newItems = [...state.items, action.payload];
      return {
        ...state,
        items: newItems,
        total: newItems.reduce((sum, item) => sum + item.price * item.qty, 0)
      };
    }
    case 'REMOVE_ITEM': {
      const newItems = state.items.filter(item => item.id !== action.payload);
      return {
        ...state,
        items: newItems,
        total: newItems.reduce((sum, item) => sum + item.price * item.qty, 0)
      };
    }
    case 'UPDATE_QTY': {
      const newItems = state.items.map(item =>
        item.id === action.payload.id
          ? { ...item, qty: action.payload.qty }
          : item
      );
      return {
        ...state,
        items: newItems,
        total: newItems.reduce((sum, item) => sum + item.price * item.qty, 0)
      };
    }
    default:
      return state;
  }
}

// Uso no componente
function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, initialCart);

  const addItem = (product) => {
    dispatch({
      type: 'ADD_ITEM',
      payload: { id: product.id, name: product.name, price: product.price, qty: 1 }
    });
  };

  return (
    <div>
      <h2>Total: R$ {cart.total.toFixed(2)}</h2>
      <ul>
        {cart.items.map(item => (
          <li key={item.id}>
            {item.name} - Qtd: {item.qty}
            <button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
              Remover
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

5. Actions Type Constants e Action Creators

Para evitar erros de digitação e centralizar a lógica, podemos usar constantes e action creators:

// actionTypes.js
export const ADD_ITEM = 'ADD_ITEM';
export const REMOVE_ITEM = 'REMOVE_ITEM';
export const UPDATE_QTY = 'UPDATE_QTY';

// actionCreators.js
export const addItem = (product) => ({
  type: ADD_ITEM,
  payload: { ...product, qty: 1 }
});

export const removeItem = (id) => ({
  type: REMOVE_ITEM,
  payload: id
});

export const updateQty = (id, qty) => ({
  type: UPDATE_QTY,
  payload: { id, qty }
});

Isso torna o código mais organizado, facilita testes e manutenção.

6. useReducer com useContext: Gerenciamento Global sem Redux

Para compartilhar estado entre múltiplos componentes sem prop drilling, combinamos useReducer com React.createContext:

// ThemeContext.js
const ThemeContext = React.createContext();

const initialState = { mode: 'light', colors: { light: '#fff', dark: '#333' } };

function themeReducer(state, action) {
  switch (action.type) {
    case 'TOGGLE_MODE':
      return { ...state, mode: state.mode === 'light' ? 'dark' : 'light' };
    case 'SET_MODE':
      return { ...state, mode: action.payload };
    default:
      return state;
  }
}

export function ThemeProvider({ children }) {
  const [state, dispatch] = useReducer(themeReducer, initialState);

  return (
    <ThemeContext.Provider value={{ theme: state, dispatch }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Componente consumidor
function Header() {
  const { theme, dispatch } = useContext(ThemeContext);

  return (
    <header style={{ background: theme.colors[theme.mode] }}>
      <button onClick={() => dispatch({ type: 'TOGGLE_MODE' })}>
        Alternar tema
      </button>
    </header>
  );
}

7. Testando Reducers e Componentes com useReducer

Reducers são funções puras, ideais para testes unitários:

// cartReducer.test.js
import { cartReducer, initialCart } from './cartReducer';

describe('cartReducer', () => {
  it('deve adicionar item ao carrinho', () => {
    const newState = cartReducer(initialCart, {
      type: 'ADD_ITEM',
      payload: { id: 1, name: 'Produto A', price: 10, qty: 2 }
    });
    expect(newState.items).toHaveLength(1);
    expect(newState.total).toBe(20);
  });

  it('deve remover item do carrinho', () => {
    const stateWithItem = {
      items: [{ id: 1, name: 'A', price: 10, qty: 1 }],
      total: 10
    };
    const newState = cartReducer(stateWithItem, {
      type: 'REMOVE_ITEM',
      payload: 1
    });
    expect(newState.items).toHaveLength(0);
    expect(newState.total).toBe(0);
  });
});

Para testar componentes, podemos simular o dispatch com React Testing Library:

import { render, screen, fireEvent } from '@testing-library/react';
import ShoppingCart from './ShoppingCart';

test('deve adicionar item ao clicar no botão', () => {
  render(<ShoppingCart />);
  fireEvent.click(screen.getByText('Adicionar'));
  expect(screen.getByText(/Total/)).toHaveTextContent('R$ 10.00');
});

8. Padrões Avançados e Armadilhas Comuns

Múltiplos reducers: Para domínios diferentes, use useReducer separados:

const [cartState, cartDispatch] = useReducer(cartReducer, initialCart);
const [userState, userDispatch] = useReducer(userReducer, initialUser);

Cuidados com closures: Em callbacks assíncronos, o estado pode estar desatualizado. Use useRef ou obtenha o estado atual via dispatch:

function handleAsync() {
  // ❌ Pode capturar estado obsoleto
  setTimeout(() => dispatch({ type: 'UPDATE', payload: state.count + 1 }), 1000);

  // ✅ Correto: dispatch sempre usa o estado mais recente
  setTimeout(() => dispatch({ type: 'INCREMENT' }), 1000);
}

Quando migrar para Redux:
- Múltiplos reducers precisam compartilhar estado
- Necessidade de middlewares (logging, side effects)
- Estado global muito grande e complexo
- Equipe grande com necessidade de padrões rígidos

useReducer oferece o equilíbrio perfeito entre simplicidade e poder para a maioria dos projetos React. Ele mantém o código previsível e testável sem a sobrecarga do Redux, sendo a escolha ideal para estado complexo em aplicações de pequeno e médio porte.

Referências