Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

useReducer no React: estado com muitas transições

Como trocar vários useState por um reducer, o formato da action e a linha em que o reducer fica melhor que o useState, com um carrinho real.

Rodolfo Mori16 min de leitura

Você remove um livro do carrinho, mas o frete continua grátis. O cupom aparece na tela, porém o total volta ao preço cheio. O clique funcionou; a memória da interface terminou com informações que não combinam.

React é uma biblioteca JavaScript para criar interfaces. Um componente é uma função que descreve uma parte da tela, e cada execução dessa função para calcular a interface é um render.

Essa memória é o estado, e cada passagem dele para o próximo valor após um evento é uma transição. useReducer é um Hook — função do React cujo nome começa com use — que reúne essas mudanças: uma ação descreve o fato, dispatch a envia e o reducer calcula o próximo estado. Você vai aplicar isso à Página 7, uma livraria fictícia, reproduzindo o bug e conferindo a correção após cada clique.

Como funciona o useReducer no React?

Ele guarda o estado e concentra no reducer as regras acionadas por dispatch. O componente anuncia o evento; a função decide as consequências.

Pense numa central de pedidos. O registro no balcão é o estado; cada cartão com “livro adicionado” ou “cupom aplicado” é uma ação; dispatch entrega o cartão; e a central, como o reducer, consulta o registro atual e produz outro. O total é consequência desse registro, não uma ordem no cartão.

A analogia termina aí: não existe uma fila física nem um banco de dados. O React agenda a atualização e chama uma função pura: entradas iguais geram o mesmo resultado, sem alterar o registro recebido nem causar efeitos externos.

Antes do código, escreva as ações em português e separe evento de resultado. “Livro foi adicionado” é um evento; “total agora é 89,90” é consequência. Isso evita setTotal, um cartão que manda obedecer em vez de contar o ocorrido.

Quando vários useState indicam a hora do useReducer?

O sinal é ter partes que mudam juntas para preservar uma invariante, regra que deve continuar verdadeira, como “carrinho vazio não tem cupom”.

O primeiro carrinho costuma usar um useState para cada informação. Cada Hook devolve o valor e um setter, a função set... que agenda sua atualização.

Experimente você mesmo

Preveja a última linha após adicionar os dois livros, aplicar o cupom e remover Grande Sertão: frete e desconto ficam corretos? Depois siga a sequência e compare sua previsão com o resumo da tela.

jsx
import { useState } from 'react';

const CATALOGO = [
  { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 },
  { id: 'vs', titulo: 'Vidas Secas', preco: 39.9 },
];

export default function CarrinhoEspalhado() {
  const [itens, setItens] = useState([]);
  const [cupom, setCupom] = useState(null);
  const [frete, setFrete] = useState(0);
  const [total, setTotal] = useState(0);

  function adicionar(livro) {
    const novos = [...itens, { ...livro, quantidade: 1 }];
    const subtotal = novos.reduce((s, i) => s + i.preco * i.quantidade, 0);
    setItens(novos);
    setFrete(subtotal >= 99 ? 0 : 12.9);
    setTotal(subtotal);
  }

  function aplicarCupom(codigo) {
    setCupom(codigo);
    setTotal(total * 0.9);
  }

  function remover(id) {
    const restantes = itens.filter((i) => i.id !== id);
    setItens(restantes);
    setTotal(restantes.reduce((s, i) => s + i.preco * i.quantidade, 0));
  }

  return (
    <div>
      <p id="resumo">
        {itens.length} itens · cupom {String(cupom)} · frete R$ {frete.toFixed(2)} · total R$ {total.toFixed(2)}
      </p>
      <button id="add-gs" onClick={() => adicionar(CATALOGO[0])}>+ Grande Sertão</button>
      <button id="add-vs" onClick={() => adicionar(CATALOGO[1])}>+ Vidas Secas</button>
      <button id="cupom" onClick={() => aplicarCupom('LEITOR10')}>Aplicar LEITOR10</button>
      <button id="rm-gs" onClick={() => remover('gs')}>- Grande Sertão</button>
    </div>
  );
}

O import traz useState, e CATALOGO fornece os livros. As quatro duplas guardam itens, cupom, frete e total. adicionar copia o array e aciona três setters; aplicarCupom muda cupom e total; remover filtra o item e refaz só o total. No return, cada botão chama um handler, função executada no clique.

A saída registra o resumo do DOM após cada um dos quatro cliques:

inicio : 0 itens · cupom null · frete R$ 0.00 · total R$ 0.00 #add-gs : 1 itens · cupom null · frete R$ 12.90 · total R$ 74.90 #add-vs : 2 itens · cupom null · frete R$ 0.00 · total R$ 114.80 #cupom : 2 itens · cupom LEITOR10 · frete R$ 0.00 · total R$ 103.32 #rm-gs : 1 itens · cupom LEITOR10 · frete R$ 0.00 · total R$ 39.90

Na última linha, sobrou R$ 39,90: o frete deveria voltar, mas segue zerado; o cupom aparece, mas o total está sem desconto. remover atualizou itens e total, porém esqueceu frete e cupom. Com a regra espalhada, cada handler vira uma nova chance de criar essa contradição.

Até aqui: o defeito não está no clique, e sim em guardar valores derivados e repetir a mesma regra em handlers separados.

Por que o reducer deve ser uma função pura?

Porque o React pode chamar o reducer para calcular uma atualização e precisa receber um resultado previsível, sem alterações escondidas. Fora do componente, essa função também pode ser executada e testada sem renderizar a interface.

Um reducer tem duas entradas — estado atual e ação — e uma saída: o próximo estado. Ele não chama Hook nem depende do componente. Ser puro significa não modificar as entradas e não fazer tarefas externas, como salvar dados ou chamar uma API, serviço com o qual o código se comunica.

js
export const CUPONS = { LEITOR10: 0.1, PAGINA7: 0.07 };

export const carrinhoVazio = { itens: [], cupom: null };

export function carrinhoReducer(estado, acao) {
  switch (acao.tipo) {
    case 'livro_adicionado': {
      const jaEsta = estado.itens.some((i) => i.id === acao.livro.id);
      const itens = jaEsta
        ? estado.itens.map((i) =>
            i.id === acao.livro.id ? { ...i, quantidade: i.quantidade + 1 } : i,
          )
        : [...estado.itens, { ...acao.livro, quantidade: 1 }];
      return { ...estado, itens };
    }

    case 'quantidade_aumentada':
      return {
        ...estado,
        itens: estado.itens.map((i) =>
          i.id === acao.id ? { ...i, quantidade: i.quantidade + 1 } : i,
        ),
      };

    case 'livro_removido': {
      const itens = estado.itens.filter((i) => i.id !== acao.id);
      return { itens, cupom: itens.length === 0 ? null : estado.cupom };
    }

    case 'cupom_aplicado':
      if (estado.itens.length === 0) return estado;
      if (!CUPONS[acao.codigo]) return estado;
      return { ...estado, cupom: acao.codigo };

    case 'carrinho_limpo':
      return carrinhoVazio;

    default:
      throw new Error(`Ação desconhecida no carrinho: ${acao.tipo}`);
  }
}

export function resumo(estado) {
  const subtotal = estado.itens.reduce((s, i) => s + i.preco * i.quantidade, 0);
  const desconto = subtotal * (CUPONS[estado.cupom] ?? 0);
  const frete = subtotal === 0 || subtotal - desconto >= 99 ? 0 : 12.9;
  return {
    subtotal: Number(subtotal.toFixed(2)),
    desconto: Number(desconto.toFixed(2)),
    frete,
    total: Number((subtotal - desconto + frete).toFixed(2)),
  };
}

CUPONS reúne códigos e percentuais, enquanto carrinhoVazio define o ponto de partida. O switch compara acao.tipo; cada case trata um evento, e default rejeita um tipo não previsto. some procura o livro, map cria a lista com a quantidade atualizada, filter remove uma linha e o operador ... copia objetos e arrays — as listas do JavaScript. São operações usadas para atualizar estado sem mutar, isto é, sem escrever por cima das referências recebidas.

Cada cartão aceito sai da central com um registro coerente. Ao remover o último livro, por exemplo, o mesmo case limpa o cupom. Já resumo calcula subtotal, desconto, frete e total a partir de itens e cupom. Eles são valores derivados — podem ser obtidos de outros dados — e por isso não precisam ser armazenados como quatro fontes de verdade.

Como simular uma sequência de ações no reducer?

Chame o reducer diretamente, passando o resultado de uma ação como estado da próxima. Isso expõe cada transição sem navegador, build ou componente e produz um roteiro reproduzível para investigar regras do carrinho.

js
import { carrinhoReducer, carrinhoVazio, resumo } from './carrinhoReducer.js';

const grandeSertao = { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 };
const vidasSecas = { id: 'vs', titulo: 'Vidas Secas', preco: 39.9 };

const roteiro = [
  { tipo: 'livro_adicionado', livro: grandeSertao },
  { tipo: 'livro_adicionado', livro: vidasSecas },
  { tipo: 'livro_adicionado', livro: vidasSecas },
  { tipo: 'cupom_aplicado', codigo: 'LEITOR10' },
  { tipo: 'livro_removido', id: 'gs' },
  { tipo: 'carrinho_limpo' },
];

let estado = carrinhoVazio;

for (const acao of roteiro) {
  estado = carrinhoReducer(estado, acao);
  const itens = estado.itens.map((i) => `${i.id}x${i.quantidade}`).join(' ') || '(vazio)';
  const { total } = resumo(estado);
  console.log(
    `${acao.tipo.padEnd(20)} -> itens: ${itens.padEnd(12)} cupom: ${String(estado.cupom).padEnd(9)} total: ${total}`,
  );
}

Os imports trazem estado inicial, reducer e cálculo do resumo. Os dois objetos representam o catálogo; roteiro é a fila de seis cartões. A variável estado começa vazia, e o for...of entrega uma ação por vez, guarda o retorno como novo estado e imprime itens, cupom e total. padEnd apenas alinha as colunas do log; não participa da regra do carrinho.

livro_adicionado -> itens: gsx1 cupom: null total: 87.8 livro_adicionado -> itens: gsx1 vsx1 cupom: null total: 114.8 livro_adicionado -> itens: gsx1 vsx2 cupom: null total: 154.7 cupom_aplicado -> itens: gsx1 vsx2 cupom: LEITOR10 total: 139.23 livro_removido -> itens: vsx2 cupom: LEITOR10 total: 84.72 carrinho_limpo -> itens: (vazio) cupom: null total: 0

O terceiro livro_adicionado não criou uma segunda linha de Vidas Secas: virou vsx2. E o livro_removido derrubou o total de R$ 139,23 para R$ 84,72 — subtotal de R$ 79,80, menos R$ 7,98 de cupom, mais os R$ 12,90 de frete que voltaram sozinhos, porque o carrinho caiu abaixo dos R$ 99.

Esse arquivo não importa React: a regra virou uma função que você roda direto. Se uma conta quebrar, o roteiro de ações registra o caminho exato até o estado incorreto.

Até aqui: o reducer concentra transições, e uma sequência de ações comprova o comportamento antes de qualquer interface entrar em cena.

O que o dispatch deve descrever?

Ele deve enviar o fato, como livro_adicionado, sem antecipar o novo estado. useReducer recebe reducer e estado inicial e devolve a dupla estado atual e dispatch.

jsx
import { useReducer } from 'react';
import { carrinhoReducer, carrinhoVazio, resumo } from './carrinhoReducer.js';

const CATALOGO = [
  { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 },
  { id: 'vs', titulo: 'Vidas Secas', preco: 39.9 },
];

export default function Carrinho() {
  const [estado, dispatch] = useReducer(carrinhoReducer, carrinhoVazio);
  const { subtotal, desconto, frete, total } = resumo(estado);

  return (
    <div>
      <p id="resumo">
        {estado.itens.length} itens · cupom {String(estado.cupom)} · subtotal R$ {subtotal.toFixed(2)} · desconto R$ {desconto.toFixed(2)} · frete R$ {frete.toFixed(2)} · total R$ {total.toFixed(2)}
      </p>
      <button id="add-gs" onClick={() => dispatch({ tipo: 'livro_adicionado', livro: CATALOGO[0] })}>
        + Grande Sertão
      </button>
      <button id="add-vs" onClick={() => dispatch({ tipo: 'livro_adicionado', livro: CATALOGO[1] })}>
        + Vidas Secas
      </button>
      <button id="cupom" onClick={() => dispatch({ tipo: 'cupom_aplicado', codigo: 'LEITOR10' })}>
        Aplicar LEITOR10
      </button>
      <button id="rm-gs" onClick={() => dispatch({ tipo: 'livro_removido', id: 'gs' })}>
        - Grande Sertão
      </button>
      <button id="rm-vs" onClick={() => dispatch({ tipo: 'livro_removido', id: 'vs' })}>
        - Vidas Secas
      </button>
    </div>
  );
}

Na desestruturação, estado é o registro e dispatch, o mensageiro. resumo(estado) deriva os números; os botões só enviam evento e dados, como livro, código ou id. As regras continuam no reducer.

Os mesmos cliques da primeira versão, mais um para esvaziar o carrinho:

#add-gs : 1 itens · cupom null · subtotal R$ 74.90 · desconto R$ 0.00 · frete R$ 12.90 · total R$ 87.80 #add-vs : 2 itens · cupom null · subtotal R$ 114.80 · desconto R$ 0.00 · frete R$ 0.00 · total R$ 114.80 #cupom : 2 itens · cupom LEITOR10 · subtotal R$ 114.80 · desconto R$ 11.48 · frete R$ 0.00 · total R$ 103.32 #rm-gs : 1 itens · cupom LEITOR10 · subtotal R$ 39.90 · desconto R$ 3.99 · frete R$ 12.90 · total R$ 48.81 #rm-vs : 0 itens · cupom null · subtotal R$ 0.00 · desconto R$ 0.00 · frete R$ 0.00 · total R$ 0.00

Em #rm-gs, frete e desconto são recalculados sem ampliar o handler; em #rm-vs, o case 'livro_removido' também limpa o cupom. Nomes como livro_adicionado descrevem o que aconteceu, enquanto setItens apenas repetiria um setter e esconderia as demais consequências.

Experimente você mesmo

Preveja o registro após duas adições do mesmo livro e um cupom: quantas linhas, qual quantidade e qual código? Depois dispare os três cliques e compare com a saída.

Outra função pode envolver o reducer para registrar ação e resultado:

jsx
import { useReducer } from 'react';
import { carrinhoReducer, carrinhoVazio } from './carrinhoReducer.js';

function comLog(reducer) {
  return (estado, acao) => {
    const proximo = reducer(estado, acao);
    const itens = proximo.itens.map((i) => `${i.id} x${i.quantidade}`).join(', ');
    console.log(acao.tipo.padEnd(20), '->', itens || '(vazio)', '| cupom:', proximo.cupom);
    return proximo;
  };
}

const carrinhoComLog = comLog(carrinhoReducer);

export default function CarrinhoComLog() {
  const [estado, dispatch] = useReducer(carrinhoComLog, carrinhoVazio);
  // ...os mesmos botões despachando as mesmas ações
}
livro_adicionado -> gs x1 | cupom: null livro_adicionado -> gs x2 | cupom: null cupom_aplicado -> gs x2 | cupom: LEITOR10

comLog devolve um reducer que chama a regra, imprime ação, itens e cupom e repassa o próximo estado. É um middleware: camada que observa ou complementa o caminho da atualização. A chamada fica fora do componente para não criar uma função nova a cada render.

Como o reducer impede estados impossíveis?

Ele valida cada ação antes de criar o próximo estado. Uma transição inválida pode devolver o mesmo objeto, preservando a invariante e permitindo que o React ignore a atualização dos filhos.

No case 'cupom_aplicado', duas linhas devolvem estado sem mudança quando o carrinho está vazio ou o código não existe. A central recebe o cartão, vê que a regra não permite a ação e devolve exatamente o registro que já tinha.

jsx
import { useReducer } from 'react';
import { carrinhoReducer, carrinhoVazio, resumo } from './carrinhoReducer.js';

const GS = { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 };

export const contagem = { carrinho: 0, rodape: 0 };

function Rodape() {
  contagem.rodape++;
  return <small>Frete grátis acima de R$ 99</small>;
}

export default function CarrinhoContado() {
  contagem.carrinho++;
  const [estado, dispatch] = useReducer(carrinhoReducer, carrinhoVazio);
  const { total } = resumo(estado);

  return (
    <div>
      <p id="resumo">
        {estado.itens.length} itens · cupom {String(estado.cupom)} · total R$ {total.toFixed(2)}
      </p>
      <button onClick={() => dispatch({ tipo: 'livro_adicionado', livro: GS })}>+ Grande Sertão</button>
      <button onClick={() => dispatch({ tipo: 'cupom_aplicado', codigo: 'LEITOR10' })}>Aplicar LEITOR10</button>
      <button onClick={() => dispatch({ tipo: 'cupom_aplicado', codigo: 'XPTO' })}>Aplicar XPTO</button>
      <Rodape />
    </div>
  );
}
montou : 0 itens · cupom null · total R$ 0.00 | Carrinho rodou 1x, Rodape 1x cupom sem livro : 0 itens · cupom null · total R$ 0.00 | Carrinho rodou 2x, Rodape 1x adiciona o livro : 1 itens · cupom null · total R$ 87.80 | Carrinho rodou 3x, Rodape 2x cupom inexistente : 1 itens · cupom null · total R$ 87.80 | Carrinho rodou 4x, Rodape 2x cupom valido : 1 itens · cupom LEITOR10 · total R$ 80.31 | Carrinho rodou 5x, Rodape 3x

O cupom não gruda em carrinho vazio nem com código inventado — a tela não muda. E o Rodape não roda de novo nesses dois cliques. O React compara o estado anterior e o próximo com Object.is, uma comparação de identidade. Ele ainda pode chamar CarrinhoContado para conferir a atualização, mas, ao receber a mesma referência, descarta esse resultado e pula os filhos. É a diferença entre Carrinho rodou 4x e Rodape 2x na quarta linha. Se esse mecanismo te interessa, ele é o assunto de por que o componente roda de novo.

Com quatro useState soltos, nada impede um setCupom('XPTO') chamado de um canto qualquer da tela. Com o reducer, existe um único portão por onde toda mudança passa, e a regra “cupom só existe com carrinho cheio” mora nele.

Por que um reducer que muta não atualiza a tela?

Porque mutação altera o objeto, mas mantém sua referência — a identidade comparada pelo React. Devolver esse mesmo objeto pode fazer a atualização ser descartada.

No balcão, é como rasurar o registro antigo e devolvê-lo com a mesma etiqueta, em vez de produzir um registro novo. O reducer abaixo faz isso ao alterar o array recebido e devolver o mesmo estado:

jsx
import { useReducer } from 'react';

const GS = { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 };

function reducerQueMuta(estado, acao) {
  switch (acao.tipo) {
    case 'livro_adicionado':
      estado.itens.push({ ...acao.livro, quantidade: 1 });
      return estado;
    default:
      return estado;
  }
}

export default function CarrinhoMutante() {
  const [estado, dispatch] = useReducer(reducerQueMuta, { itens: [] });
  console.log('render — itens no estado:', estado.itens.length);

  return (
    <div>
      <p id="resumo">Carrinho: {estado.itens.length} livros</p>
      <button onClick={() => dispatch({ tipo: 'livro_adicionado', livro: GS })}>
        + Grande Sertão
      </button>
    </div>
  );
}

push escreve no array original, e o reducer retorna estado. O componente conta renders e mostra o tamanho; por compartilhar a referência antiga, log e DOM passam a discordar:

render — itens no estado: 0 na tela: Carrinho: 0 livros render — itens no estado: 1 na tela: Carrinho: 0 livros render — itens no estado: 2 na tela: Carrinho: 0 livros

O log vê 1 e depois 2 itens, mas a tela continua em zero. Como o objeto devolvido é o mesmo, o React descarta o resultado que o componente calculou.

O que acontece quando o tipo da action está errado?

O default lança uma exceção no ponto em que a ação desconhecida chega. Em vez de deixar um estado inválido avançar, a mensagem mostra qual tipo precisa ser corrigido.

Aqui, o reducer espera livro_adicionado, mas recebe adicionar_livro:

js
import { carrinhoReducer, carrinhoVazio } from './carrinhoReducer.js';

const GS = { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 };

carrinhoReducer(carrinhoVazio, { tipo: 'adicionar_livro', livro: GS });
file:///private/tmp/pagina7/carrinhoReducer.js:39 throw new Error(`Ação desconhecida no carrinho: ${acao.tipo}`); ^

Error: Ação desconhecida no carrinho: adicionar_livro at carrinhoReducer (file:///private/tmp/pagina7/carrinhoReducer.js:39:13) at file:///private/tmp/pagina7/carrinho.mjs:5:1 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.16.0

A mensagem traz o nome errado. Aqui o reducer foi chamado no Node para deixar o rastro limpo; no navegador, a exceção chega ao error boundary, componente preparado para capturar falhas de renderização, com a mesma frase.

Sem o default que lança, esse reducer devolveria undefined como novo estado, e a quebra apareceria só no render seguinte, longe da causa, com um Cannot read properties of undefined. O throw no default troca um bug silencioso por um erro no lugar certo. Com TypeScript, os tipos aceitos podem virar uma união, isto é, uma lista fechada que o editor verifica antes da execução.

Até aqui: retornar uma referência antiga pode esconder a atualização; rejeitar um tipo desconhecido torna o outro erro visível perto da causa.

Como combinar useReducer com Context API?

Context disponibiliza estado e dispatch a componentes distantes sem repassar essas props por cada nível. Dois contextos deixam cada componente ler só o que usa.

Um carrinho aparece no cabeçalho, na estante e no fechamento. Props são dados passados de pai para filho; atravessar níveis que não as usam é prop drilling. A Context API cria um canal compartilhado: o provider oferece o valor, e componentes consumidores o leem com useContext.

O detalhe que faz esse par funcionar é a estabilidade do dispatch:

jsx
import { useReducer } from 'react';
import { carrinhoReducer, carrinhoVazio } from './carrinhoReducer.js';

const GS = { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 };
let anterior = null;

export default function DispatchEstavel() {
  const [estado, dispatch] = useReducer(carrinhoReducer, carrinhoVazio);

  console.log(
    'render',
    estado.itens.length,
    '— mesmo dispatch de antes?',
    anterior === null ? '(primeiro render)' : Object.is(anterior, dispatch),
  );
  anterior = dispatch;

  return <button onClick={() => dispatch({ tipo: 'livro_adicionado', livro: GS })}>+ Grande Sertão</button>;
}
render 0 — mesmo dispatch de antes? (primeiro render) render 1 — mesmo dispatch de antes? true render 1 — mesmo dispatch de antes? true

O React garante identidade estável para dispatch, confirmada por Object.is na saída. Como DispatchContexto recebe exatamente essa função, mudar o carrinho não altera esse contexto nem notifica consumidores por esse motivo. Eles ainda podem renderizar por causa do pai, do próprio estado ou de outro contexto.

jsx
import { createContext, useContext, useReducer } from 'react';
import { carrinhoReducer, carrinhoVazio, resumo } from './carrinhoReducer.js';

const CarrinhoContexto = createContext(null);
const DispatchContexto = createContext(null);

export function CarrinhoProvider({ children }) {
  const [estado, dispatch] = useReducer(carrinhoReducer, carrinhoVazio);
  return (
    <CarrinhoContexto.Provider value={estado}>
      <DispatchContexto.Provider value={dispatch}>{children}</DispatchContexto.Provider>
    </CarrinhoContexto.Provider>
  );
}

export const useCarrinho = () => useContext(CarrinhoContexto);
export const useDispatchCarrinho = () => useContext(DispatchContexto);

function Cabecalho() {
  const estado = useCarrinho();
  const { total } = resumo(estado);
  return <header id="cabecalho">Página 7 · {estado.itens.length} livros · R$ {total.toFixed(2)}</header>;
}

function CartaoLivro({ livro }) {
  const dispatch = useDispatchCarrinho();
  return (
    <button id={`add-${livro.id}`} onClick={() => dispatch({ tipo: 'livro_adicionado', livro })}>
      {livro.titulo}
    </button>
  );
}

createContext abre os canais; CarrinhoProvider publica estado e dispatch; children é a árvore dentro dele. Os Hooks próprios escondem useContext. Cabecalho lê estado, enquanto CartaoLivro recebe o livro e lê dispatch.

inicio : Página 7 · 0 livros · R$ 0.00 #add-gs: Página 7 · 1 livros · R$ 87.80 #add-vs: Página 7 · 2 livros · R$ 114.80 #add-vs: Página 7 · 2 livros · R$ 154.70

O CartaoLivro está três níveis abaixo do provider e não recebeu nenhuma prop de carrinho. Ele conhece um livro e sabe anunciar um fato. Envolver o useContext em hooks customizados como useCarrinho também deixa um lugar para lançar um erro claro se alguém usar o hook fora do provider.

Como escrever testes automatizados para o reducer?

Passe estados e ações conhecidos ao reducer e compare o retorno com o resultado esperado. Como ele é puro, o teste não precisa montar componente nem simular o DOM.

node:test é o executor de testes incluído no Node; assert oferece as verificações que fazem o teste falhar quando um valor foge do esperado.

js
import test from 'node:test';
import assert from 'node:assert/strict';
import { carrinhoReducer, carrinhoVazio, resumo } from './carrinhoReducer.js';

const GS = { id: 'gs', titulo: 'Grande Sertão: Veredas', preco: 74.9 };

test('o mesmo livro duas vezes vira quantidade 2, não duas linhas', () => {
  let estado = carrinhoReducer(carrinhoVazio, { tipo: 'livro_adicionado', livro: GS });
  estado = carrinhoReducer(estado, { tipo: 'livro_adicionado', livro: GS });

  assert.equal(estado.itens.length, 1);
  assert.equal(estado.itens[0].quantidade, 2);
});

test('remover o último livro derruba o cupom junto', () => {
  let estado = carrinhoReducer(carrinhoVazio, { tipo: 'livro_adicionado', livro: GS });
  estado = carrinhoReducer(estado, { tipo: 'cupom_aplicado', codigo: 'LEITOR10' });
  estado = carrinhoReducer(estado, { tipo: 'livro_removido', id: 'gs' });

  assert.deepEqual(estado, carrinhoVazio);
  assert.equal(resumo(estado).total, 0);
});

test('cupom em carrinho vazio devolve o mesmo objeto', () => {
  const depois = carrinhoReducer(carrinhoVazio, { tipo: 'cupom_aplicado', codigo: 'LEITOR10' });
  assert.ok(Object.is(carrinhoVazio, depois));
});

test('o reducer não encosta no estado que recebeu', () => {
  const antes = { itens: [{ ...GS, quantidade: 1 }], cupom: null };
  const copia = structuredClone(antes);

  carrinhoReducer(antes, { tipo: 'quantidade_aumentada', id: 'gs' });

  assert.deepEqual(antes, copia);
});

Os dois primeiros testes repetem livro e removem o último item para verificar quantidade, cupom e total. O terceiro usa Object.is para confirmar que uma ação inválida devolve exatamente o mesmo estado. O quarto cria uma cópia com structuredClone, executa uma ação e compara o estado original com essa cópia; se o reducer o tivesse mutado, deepEqual encontraria a diferença.

bash
node --test
✔ o mesmo livro duas vezes vira quantidade 2, não duas linhas (0.3605ms) ✔ remover o último livro derruba o cupom junto (0.316ms) ✔ cupom em carrinho vazio devolve o mesmo objeto (0.065834ms) ✔ o reducer não encosta no estado que recebeu (0.071042ms) ℹ tests 4 ℹ suites 0 ℹ pass 4 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 54.973667

O comando node --test encontrou quatro casos, e pass 4 com fail 0 confirma que todos passaram nessa execução. A duração mede a corrida; não é uma promessa de desempenho para outras máquinas.

Para que serve o terceiro argumento do useReducer?

Ele recebe uma função inicializadora que transforma o segundo argumento no estado da montagem. Isso evita repetir, a cada render, uma preparação que só é necessária no início.

Um uso comum é a reidratação: reconstruir o estado a partir de texto salvo no localStorage, o armazenamento do navegador. JSON.parse transforma esse texto em formato JSON — uma forma padronizada de representar dados — em valores JavaScript, que ainda precisam ser validados porque podem estar antigos ou corrompidos.

jsx
import { useReducer } from 'react';
import { carrinhoReducer, carrinhoVazio, resumo } from './carrinhoReducer.js';

// o rascunho que chega do localStorage, com uma linha corrompida no meio:
// {"itens":[{"id":"vs","titulo":"Vidas Secas","preco":39.9,"quantidade":2},
//           {"id":"","quantidade":0}],"cupom":"LEITOR10"}

function reidratar(bruto) {
  console.log('reidratar rodou');
  try {
    const salvo = JSON.parse(bruto);
    const itens = (salvo.itens ?? []).filter((i) => i.id && i.quantidade > 0);
    return { itens, cupom: itens.length > 0 ? (salvo.cupom ?? null) : null };
  } catch {
    return carrinhoVazio;
  }
}

export default function CarrinhoSalvo({ rascunho }) {
  const [estado, dispatch] = useReducer(carrinhoReducer, rascunho, reidratar);
  const { total } = resumo(estado);
  // ...
}
reidratar rodou montou : 1 itens · cupom LEITOR10 · total R$ 84.72 clique : 2 itens · cupom LEITOR10 · total R$ 139.23 clique : 2 itens · cupom LEITOR10 · total R$ 206.64

reidratar converte o texto, filtra linhas sem id ou quantidade positiva e só conserva o cupom se houver itens; catch cobre JSON inválido. Em useReducer, rascunho é a entrada bruta e reidratar, o terceiro argumento.

Uma linha era válida e outra corrompida, por isso a montagem mostra um item. Nesta medição sem StrictMode, “reidratar rodou” apareceu uma vez. Em StrictMode de desenvolvimento, o React pode chamar a inicializadora duas vezes e usar só um resultado; ela também precisa ser pura. Sem o terceiro argumento, parse e filtro no corpo se repetiriam a cada render.

Quando usar useReducer ou useState?

Use useState para valores independentes e transições curtas; considere useReducer quando várias mudanças dependem umas das outras, regras se repetem ou você precisa testar a lógica separadamente. A tabela transforma esses sinais em uma decisão prática.

situação com useState com useReducer
um valor solto: o texto de um input, um modal aberto é o caminho natural cerimônia sem retorno
dois ou três valores que mudam em momentos diferentes funciona bem desnecessário
quatro ou mais campos que mudam sempre juntos quatro setters em cada handler uma action por handler
a próxima mudança depende de outra parte do estado você lê valor velho ou empilha useEffect o reducer recebe o estado inteiro
a mesma mudança é disparada de três telas o handler é copiado três vezes um case, três dispatch
quer testar a regra sem montar componente precisa renderizar é uma função exportada
existe combinação de valores que não pode existir nada impede o reducer é o único portão

A pergunta prática é: alguém consegue listar tudo que pode acontecer com esse estado olhando um único lugar? Com o reducer, a resposta está no switch. Com setters espalhados, é preciso rastrear todos os handlers.

E não é uma escolha para o componente inteiro: o carrinho pode viver num reducer, e o campo de busca do cabeçalho, ao lado, continuar num useState de uma linha. Migrar depois também é barato — os quatro useState da primeira seção viraram este reducer com os mesmos botões, na mesma ordem, trocando três chamadas de setter por um dispatch.

Até aqui: escolha o Hook pela forma das transições, não pela quantidade de linhas. Um valor isolado pode continuar em useState ao lado de um carrinho governado por reducer.

O que aprender depois de useReducer?

O próximo passo da trilha é entender como children — o conteúdo colocado dentro de um componente — e composição organizam a árvore em que esse estado circula. Você vai separar responsabilidades sem criar um componente gigante nem repassar detalhes desnecessários.

Continue em children e composição no React. Depois, a trilha de React leva a rotas e organização do projeto.

  • react
  • usereducer
  • estado
  • reducer
  • carrinho

Perguntas frequentes

useReducer substitui o Redux?
Para o estado de uma tela ou de uma área do app, sim — é a mesma ideia de reducer e action, sem instalar nada. O Redux continua fazendo sentido quando você quer uma única loja global, DevTools com viagem no tempo e middlewares prontos num app grande, com muitos times mexendo.
Posso chamar uma API dentro do reducer?
Não. O reducer precisa ser puro: mesma entrada, mesma saída, sem efeito colateral. A chamada fica no handler do evento ou num useEffect, e o resultado volta como uma nova action, do tipo dados_recebidos.
Preciso usar switch no reducer?
Não, é só convenção. Dá para usar um objeto que mapeia tipo de ação para função, ou uma sequência de if. O switch venceu porque deixa a lista de transições legível de cima a baixo e obriga a pensar no caso padrão.
O dispatch precisa entrar na lista de dependências do useEffect?
Não. O React garante que a função dispatch é a mesma em todos os renders do componente, então incluí-la não muda nada e omiti-la não quebra o lint. É por isso que ela pode ser guardada num contexto sem useMemo.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. React — useReducer — react.dev
  2. React — Extracting State Logic into a Reducer — react.dev
  3. React — Scaling Up with Reducer and Context — react.dev

Continue por aqui