Como funciona o Reducer do React

Uma explicação didática sobre a diferença entre useState e useReducer, com exemplos práticos de um contador e de um formulário para você entender de vez quando usar cada um.


O useReducer é um mistério para muita gente. Quando você entende como ele funciona, percebe que é simples de usar e que, em algumas situações, é a melhor alternativa disponível. Mas como a gente não sabe direito como criar um, acaba fazendo gambiarra com outros recursos só para não mexer nele. Esse post tem o propósito de simplificar isso de uma vez por todas, com exemplos práticos.

State ou reducer, qual a diferença

No React, você tem dois recursos nativos para gerenciar estado, ou seja, para gerenciar as mudanças de dados ao longo do uso do sistema: useState e useReducer. Os dois têm a mesma finalidade, a diferença está em quando usar cada um.

Quando você tem um dado único, um número, uma string, um boolean, algo que você manipula diretamente, o melhor a ser utilizado é o useState. Dá para usar um reducer nesse caso também, mas você estaria complicando algo que poderia ser simples.

Quando você tem uma entidade maior, com várias propriedades relacionadas entre si, como um carrinho de compras que é uma lista de produtos, cada um com id, nome, imagem, preço e quantidade, um useState sozinho (ou vários deles) vira uma gambiarra rapidamente. Nesses casos o reducer é de longe a melhor opção.

Exemplo 1: um contador com useState

Para deixar isso concreto, vamos montar um contador simples: um número no meio, um botão de menos e um botão de mais.

const [count, setCount] = useState<number>(0)

O botão de mais é direto, sempre soma um ao valor atual:

function increment() {
  setCount(count + 1)
}

No botão de menos, vale a pena travar o valor em zero para não deixar a contagem ficar negativa:

function decrement() {
  if (count > 0) {
    setCount(count - 1)
  }
}

Pronto, esse é um caso clássico de dado simples, então useState resolve sem complicação nenhuma.

Exemplo 2: um formulário com useReducer

Agora um caso diferente: um formulário com campo de nome e campo de sobrenome, que exibe o nome completo logo abaixo.

Nome e sobrenome são só dois textos, então dá para resolver com dois useState separados sem problema. Mas também dá para olhar os dois como propriedades de uma entidade maior, uma pessoa, e nesse caso o useReducer também é uma opção válida. Vou usar reducer aqui justamente para explicar o conceito.

Criar um reducer tem duas etapas: primeiro você define quais ações podem ser executadas, depois você cria o uso desse reducer.

Etapa 1: criando o reducer

O reducer é uma função que recebe dois parâmetros. O primeiro é o state, que são os dados que estão na memória naquele momento:

type Person = {
  firstName: string
  lastName: string
}

O segundo parâmetro é a ação que você quer executar. Nesse caso existem duas ações possíveis: trocar o primeiro nome ou trocar o sobrenome.

type Action =
  | { type: "CHANGE_FIRST_NAME"; value: string }
  | { type: "CHANGE_LAST_NAME"; value: string }

Com os tipos prontos, a função do reducer usa um switch no type da ação para decidir o que fazer. Em cada case, ela retorna um novo objeto clonando o state atual e sobrescrevendo só o campo que mudou. Se a ação não for nenhuma das esperadas, ela devolve o state sem alterar nada:

function personReducer(state: Person, action: Action): Person {
  switch (action.type) {
    case "CHANGE_FIRST_NAME":
      return { ...state, firstName: action.value }
    case "CHANGE_LAST_NAME":
      return { ...state, lastName: action.value }
    default:
      return state
  }
}

Etapa 2: usando o reducer

Para deixar organizado, esse uso costuma ficar isolado num hook próprio:

const initialState: Person = {
  firstName: "",
  lastName: "",
}
 
export function usePerson() {
  return useReducer(personReducer, initialState)
}

O useReducer recebe dois parâmetros: a função reducer que você criou e o estado inicial, exatamente igual ao segundo parâmetro do useState, só que aqui é um objeto em vez de um valor único.

Usando no formulário

No componente, o uso é parecido com o useState, mudando o nome das variáveis retornadas:

const [person, dispatch] = usePerson()

O dispatch é a função que executa as ações. Diferente de um setName que só alteraria o nome, o dispatch consegue executar qualquer uma das ações que você definiu no reducer:

<input
  value={person.firstName}
  onChange={(e) =>
    dispatch({ type: "CHANGE_FIRST_NAME", value: e.target.value })
  }
/>
 
<input
  value={person.lastName}
  onChange={(e) =>
    dispatch({ type: "CHANGE_LAST_NAME", value: e.target.value })
  }
/>
 
<p>{person.firstName} {person.lastName}</p>

Com TypeScript configurado, o autocomplete já mostra as opções de type disponíveis na hora de escrever o dispatch, o que ajuda bastante a não errar o nome da ação.

Resumindo

useState resolve bem dados simples e isolados. useReducer entra quando você tem uma entidade com várias propriedades relacionadas e várias ações possíveis sobre ela, como um carrinho de compras: adicionar produto, alterar quantidade, remover item. Nesses casos, criar um reducer com as ações bem definidas evita a gambiarra de ficar controlando tudo com vários states soltos.

Se você quer fixar o conceito, um bom exercício é tentar montar um carrinho de compras usando reducer, com as ações de adicionar, remover e alterar quantidade de um produto. Vai dar um trabalho, mas é o tipo de exercício que ensina de verdade.