← Blog

UX de carrinho de compras em React: atualizações otimistas, desfazer e dinheiro em unidades menores

Um carrinho em React que parece instantâneo e continua correto: dinheiro em unidades menores, quantidades otimistas com rollback, requisições combinadas, desfazer e progresso do frete grátis.

Preet Suthar · · 6 min de leitura

  • React
  • Next.js
  • E-commerce
  • UX

Um carrinho de compras é a primeira parte de uma loja que precisa parecer instantânea e estar correta ao mesmo tempo. Pressione o mais e o número deve mudar agora, não depois de uma ida e volta ao servidor. Mas o servidor é dono do estoque, dos preços e dos descontos, e às vezes ele vai dizer não.

É assim que eu construo um carrinho em React e Next.js que faz as duas coisas: dinheiro armazenado como inteiros, atualizações otimistas que revertem sem sujeira, requisições combinadas, desfazer em vez de diálogos de confirmação e uma barra de frete grátis que não mente.

Comece pelo dinheiro

Antes de qualquer UI, acerte como você armazena dinheiro, ou todo total daqui para a frente vai sair um pouco errado.

0.1 + 0.2

Isso dá 0.30000000000000004, e um carrinho soma muitos números. Armazene valores como inteiros na menor unidade da moeda, centavos para USD e ienes para JPY, que não tem unidade menor. Isso também é o que a Stripe e a maioria das APIs de pagamento enviam e esperam.

type Money = { amount: number; currency: string }

function addMoney(a: Money, b: Money): Money {
  if (a.currency !== b.currency) {
    throw new Error(`Can't add ${a.currency} to ${b.currency}`)
  }
  return { amount: a.amount + b.amount, currency: a.currency }
}

function formatMoney(money: Money, locale: string) {
  const format = new Intl.NumberFormat(locale, {
    style: "currency",
    currency: money.currency,
  })
  const digits = format.resolvedOptions().maximumFractionDigits ?? 2
  return format.format(money.amount / 10 ** digits)
}

formatMoney({ amount: 4800, currency: "USD" }, "en-US") dá $48.00, e o mesmo valor em JPY dá ¥4,800. O Intl.NumberFormat cuida de símbolos, agrupamento e casas decimais por locale, então 48,00 € na Alemanha vem de graça. Os provedores de pagamento têm algumas moedas em que a unidade menor difere do padrão ISO, então confira uma vez a lista do seu provedor.

Recusar somar duas moedas não é paranoia. Isso pega o bug em que um carrinho em EUR recebe um frete em USD.

Atualizações otimistas que revertem

O carrinho deve mudar no momento em que alguém pressiona o mais: quantidade, total da linha e subtotal. A requisição roda por trás. Se falhar, a linha volta para a última quantidade que o servidor confirmou e diz o motivo abaixo da linha, por exemplo "Só restam 2 em Sand / Large".

O useOptimistic do React 19 se encaixa bem aqui. O valor otimista aparece enquanto a transition roda, e o React o descarta quando a transition termina, então uma falha é revertida sem código extra:

const [cart, setCart] = useState(initialCart)
const [shownCart, showChange] = useOptimistic(cart, applyLineChange)

function changeQuantity(lineId: string, quantity: number) {
  startTransition(async () => {
    showChange({ lineId, quantity })
    const result = await updateLine(lineId, quantity)
    startTransition(() => {
      if (result.ok) setCart(result.cart)
      else setLineError(lineId, result.message)
    })
  })
}

Duas regras mantêm tudo honesto. O servidor retorna o carrinho inteiro depois de cada mudança, e esse carrinho substitui o seu, então totais, descontos e frete sempre vêm de um só lugar. E o erro da linha fica até o cliente mudar aquela linha de novo, porque uma mensagem que some antes de ser lida é o mesmo que nenhuma mensagem.

No Next.js, updateLine pode ser uma server action. O Next.js esconde em produção as mensagens de erro lançadas por server actions, então retorne { ok: false, message } para os erros que você quer mostrar em vez de lançá-los.

Junte cliques rápidos

Quem quer cinco unidades de algo pressiona o mais quatro vezes em um segundo. Enviar quatro requisições cria quatro chances de falha e quatro respostas que podem chegar fora de ordem. Em vez disso, envie uma requisição com a quantidade final.

const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())

function pressQuantity(lineId: string, quantity: number) {
  showLocally(lineId, quantity)
  clearTimeout(timers.current.get(lineId))
  timers.current.set(
    lineId,
    setTimeout(() => commitQuantity(lineId, quantity), 400)
  )
}

Envie a quantidade absoluta, nunca "mais um". Um valor absoluto é seguro para repetir e seguro para chegar atrasado. Se duas requisições para a mesma linha se sobrepuserem, ignore qualquer resposta mais antiga que a última que você enviou.

Desfazer em vez de "Tem certeza?"

Um diálogo de confirmação ao remover adiciona uma etapa a toda remoção para proteger contra o erro raro. O desfazer protege contra o mesmo erro e não custa nada quando não há erro.

Recolha a linha removida numa linha curta, "Ring Vase removido · Desfazer", por cerca de cinco segundos, e deslize as linhas de baixo para cima até o lugar. Desfazer devolve a linha para onde ela estava, não para o fim. Você pode removê-la no servidor na hora e adicioná-la de volta ao desfazer, ou esperar a linha expirar. Remover na hora mantém o carrinho do servidor correto se a aba for fechada, então eu prefiro assim, com a posição original guardada no cliente.

Anuncie a remoção por uma live region educada e mantenha o foco num lugar sensato, no botão Desfazer ou na próxima linha, em vez de deixá-lo cair de volta para a página.

Linhas desatualizadas

Um carrinho pode ficar parado numa aba por dias. Quando ele for carregado de novo, o servidor deve marcar as linhas cujo preço mudou desde que foram adicionadas ("O preço caiu para $58") e as linhas que esgotaram. Uma linha esgotada bloqueia o checkout até ser removida, e o motivo fica na própria linha, não num toast.

Uma barra de frete grátis que diz a verdade

"Adicione mais $12.00 para ganhar frete grátis" é uma das poucas mensagens de carrinho de que as pessoas realmente gostam. Duas coisas precisam estar certas:

  • Use o mesmo subtotal que o servidor. Se o frete grátis é calculado depois dos descontos, a barra também precisa ser, senão a barra diz que você se qualifica e o checkout cobra o frete.
  • Comemore uma vez. Toque um momento curto quando o limite for atingido e depois mostre um discreto "Você ganhou frete grátis". Não repita a cada renderização nem toda vez que o drawer abrir.

Use um elemento progress de verdade com um texto, para que a barra seja lida como "$138 de $150" e não como uma faixa colorida.

O botão de adicionar à sacola e o badge

Pequenos detalhes que o cliente vê em toda página de produto:

  • O botão mantém a largura enquanto passa de texto para spinner e para check, para que nada ao lado dele pule.
  • O spinner espera cerca de 300ms antes de aparecer. A maioria das adições termina mais rápido e nunca precisa dele.
  • O badge da sacola muda só o dígito que mudou, e o drawer abre ou aparece uma confirmação curta, para que as pessoas saibam para onde o item foi.

Um checklist

  • Dinheiro como inteiros em unidades menores, formatado com Intl.NumberFormat.
  • Nunca some duas moedas.
  • Quantidades otimistas, revertidas para o último carrinho confirmado com uma mensagem na linha.
  • Cliques rápidos juntados numa única requisição com a quantidade final.
  • Desfazer ao remover por cerca de cinco segundos, restaurando a posição da linha.
  • Preços desatualizados e linhas esgotadas sinalizados na própria linha, bloqueando o checkout quando necessário.
  • Progresso do frete grátis calculado a partir do mesmo subtotal que o servidor.

Os blocos do HextaUI para isso

Essas são as regras por trás do bloco Cart do HextaUI Pro. Ele tem um AddToCartButton, um CartTrigger com contagem ao vivo e um CartDrawer que desliza da direita no desktop e de baixo nos celulares, além de um CartPanel para uma página de carrinho completa. Cliques rápidos se juntam numa única requisição, um callback que falha reverte a linha e mostra a sua mensagem, linhas removidas se recolhem numa linha de Desfazer por cinco segundos, e a mesma lógica é exportada como useOptimisticCart para os seus próprios layouts. Os exemplos cobrem a Shopify Storefront API e server actions do Next.js.

O Order Summary recebe o mesmo formato de carrinho para o checkout, com cupons de desconto, impostos estimados e linhas cujo preço mudou. Os dois usam os helpers de dinheiro do Price, que trabalham em unidades menores inteiras e se recusam a somar duas moedas. Você pode testar todos nos previews ao vivo.

Com o carrinho pronto, o próximo passo é o checkout: um checkout de uma página em React e Next.js.

FAQ

Devo armazenar preços como floats? Não. Armazene inteiros na unidade menor da moeda e só divida na hora de formatar.

As atualizações do carrinho devem ser otimistas? Sim para quantidade e remoção, desde que o carrinho do servidor substitua o seu depois de cada mudança e as falhas sejam revertidas com um motivo.

Desfazer ou um diálogo de confirmação ao remover um item? Desfazer. É mais rápido quando a pessoa realmente quer remover e igualmente seguro quando não quer.

Como eu evito uma requisição a cada clique no mais? Espere algumas centenas de milissegundos depois do último clique e envie a quantidade final.