UX del carrito de compra en React: actualizaciones optimistas, deshacer y dinero en unidades menores
Un carrito en React instantáneo y correcto: dinero en unidades menores, cantidades optimistas con reversión, peticiones agrupadas, deshacer y envío gratis.
Preet Suthar · · 6 min de lectura
- React
- Next.js
- E-commerce
- UX
Un carrito de compra es la primera pieza de una tienda que tiene que sentirse instantánea y ser correcta a la vez. Pulsa más y el número debería cambiar ya, no tras un viaje de ida y vuelta. Pero el servidor manda sobre el stock, los precios y los descuentos, y a veces dirá que no.
Así es como construyo en React y Next.js un carrito que hace ambas cosas: dinero guardado como enteros, actualizaciones optimistas que se revierten limpiamente, peticiones agrupadas, deshacer en lugar de diálogos de confirmación y una barra de envío gratis que no miente.
Empieza por el dinero
Antes de cualquier UI, arregla cómo guardas el dinero, o todos los totales a partir de ahí estarán ligeramente mal.
Eso es 0.30000000000000004, y un carrito suma muchos números. Guarda los importes como enteros en la unidad más pequeña de la moneda, céntimos para USD y yenes para JPY, que no tiene unidad menor. Es también lo que envían y esperan Stripe y la mayoría de las API de pago.
formatMoney({ amount: 4800, currency: "USD" }, "en-US") da $48.00, y el mismo importe en JPY da ¥4,800. Intl.NumberFormat gestiona símbolos, separadores de miles y decimales según el locale, así que 48,00 € en Alemania sale gratis. Los proveedores de pago tienen unas pocas monedas cuya unidad menor difiere del estándar ISO, así que revisa una vez la lista de tu proveedor.
Negarse a sumar dos monedas no es paranoia. Detecta el bug en el que un carrito en EUR recibe una tarifa de envío en USD.
Actualizaciones optimistas que se revierten
El carrito debe cambiar en el momento en que alguien pulsa más: la cantidad, el total de la línea y el subtotal. La petición se ejecuta por detrás. Si falla, la línea vuelve a la última cantidad que confirmó el servidor y explica por qué debajo de la línea, por ejemplo "Solo quedan 2 en Sand / Large".
useOptimistic de React 19 encaja bien aquí. El valor optimista se muestra mientras se ejecuta la transición, y React lo descarta cuando termina, así que un fallo se revierte sin código adicional:
Dos reglas lo mantienen honesto. El servidor devuelve el carrito completo tras cada cambio, y ese carrito sustituye al tuyo, así que los totales, los descuentos y el envío siempre salen de un mismo sitio. Y el error de la línea se queda hasta que el cliente vuelve a cambiar esa línea, porque un mensaje que desaparece antes de leerse es lo mismo que ningún mensaje.
En Next.js, updateLine puede ser una server action. Next.js oculta en producción los mensajes de los errores lanzados desde server actions, así que devuelve { ok: false, message } para los errores que quieras mostrar en lugar de lanzarlos.
Agrupa las pulsaciones rápidas
Alguien que quiere cinco unidades de algo pulsa más cuatro veces en un segundo. Enviar cuatro peticiones crea cuatro oportunidades de fallo y cuatro respuestas que pueden llegar desordenadas. Envía en su lugar una sola petición con la cantidad final.
Envía la cantidad absoluta, nunca "más uno". Un valor absoluto se puede reintentar sin riesgo y recibir tarde sin riesgo. Si dos peticiones para la misma línea se solapan, ignora cualquier respuesta más antigua que la última que enviaste.
Deshacer en lugar de "¿Estás seguro?"
Un diálogo de confirmación al eliminar añade un paso a cada eliminación para proteger contra el error poco frecuente. Deshacer protege contra el mismo error y no cuesta nada cuando no lo hay.
Contrae la línea eliminada en una fila corta, "Ring Vase eliminado · Deshacer", durante unos cinco segundos, y desliza hacia arriba las líneas de debajo hasta su sitio. Deshacer devuelve la línea a donde estaba, no al final. Puedes eliminarla en el servidor al momento y volver a añadirla al deshacer, o esperar a que caduque la fila. Eliminarla al momento mantiene correcto el carrito del servidor si se cierra la pestaña, así que lo prefiero, guardando la posición original en el cliente.
Anuncia la eliminación con una live region educada y deja el foco en un sitio razonable, en el botón Deshacer o en la siguiente línea, en lugar de dejar que vuelva a la página.
Líneas que se quedaron desactualizadas
Un carrito puede pasar días en una pestaña. Cuando se vuelve a cargar, el servidor debe marcar las líneas cuyo precio cambió desde que se añadieron ("El precio bajó a $58") y las que se agotaron. Una línea agotada bloquea el checkout hasta que se elimina, y el motivo aparece en la propia línea, no en un toast.
Una barra de envío gratis que dice la verdad
"Añade $12.00 más para el envío gratis" es uno de los pocos mensajes del carrito que de verdad le gustan a la gente. Hay dos cosas que hacer bien:
- Usa el mismo subtotal que el servidor. Si el envío gratis se calcula después de los descuentos, la barra también debe hacerlo, o la barra dirá que cumples los requisitos y el checkout cobrará el envío.
- Celébralo una vez. Reproduce un momento breve al cruzar el umbral y luego muestra un discreto "Tienes envío gratis". No lo repitas en cada render ni cada vez que se abra el drawer.
Usa un elemento de progreso real con una etiqueta de texto, para que la barra se lea como "$138 de $150" y no como una franja de color.
El botón de añadir a la bolsa y el badge
Pequeños detalles que el cliente ve en cada página de producto:
- El botón mantiene su ancho mientras pasa de la etiqueta al spinner y al check, para que nada a su lado salte.
- El spinner espera unos 300ms antes de aparecer. La mayoría de las adiciones terminan antes y nunca lo necesitan.
- El badge de la bolsa cambia solo el dígito que cambió, y se abre el drawer o aparece una breve confirmación, para que la gente sepa adónde fue el artículo.
Una checklist
- El dinero como enteros en unidades menores, formateado con
Intl.NumberFormat. - Nunca sumes dos monedas.
- Cantidades optimistas, revertidas al último carrito confirmado con un mensaje en la línea.
- Pulsaciones rápidas agrupadas en una sola petición con la cantidad final.
- Deshacer al eliminar durante unos cinco segundos, restaurando la posición de la línea.
- Precios desactualizados y líneas agotadas marcados en la línea, bloqueando el checkout cuando hace falta.
- Progreso del envío gratis a partir del mismo subtotal que el servidor.
Los bloques de HextaUI para esto
Estas son las reglas detrás del bloque Cart de HextaUI Pro. Tiene un AddToCartButton, un CartTrigger con un contador en vivo y un CartDrawer que entra desde la derecha en escritorio y desde abajo en móviles, además de un CartPanel para una página de carrito completa. Las pulsaciones rápidas se agrupan en una sola petición, un callback fallido revierte la línea y muestra tu mensaje, las líneas eliminadas se contraen en una fila de Deshacer durante cinco segundos, y la misma lógica se exporta como useOptimisticCart para tus propios layouts. Sus ejemplos cubren la Shopify Storefront API y las server actions de Next.js.
Order Summary recibe la misma forma de carrito para el checkout, con códigos de descuento, impuestos estimados y líneas cuyo precio cambió. Ambos usan los helpers de dinero de Price, que trabajan en enteros de la unidad menor y se niegan a sumar dos monedas. Puedes probarlos todos en las vistas previas en vivo.
Cuando el carrito esté listo, el siguiente paso es el checkout: un checkout de una página en React y Next.js.
Preguntas frecuentes
¿Debo guardar los precios como floats? No. Guarda enteros en la unidad menor de la moneda y divide solo al formatear.
¿Las actualizaciones del carrito deben ser optimistas? Sí para la cantidad y para eliminar, siempre que el carrito del servidor sustituya al tuyo después de cada cambio y los fallos se reviertan con un motivo.
¿Deshacer o un diálogo de confirmación al eliminar un artículo? Deshacer. Es más rápido cuando la gente lo hace a propósito e igual de seguro cuando no.
¿Cómo evito una petición por cada pulsación de más? Espera unos cientos de milisegundos tras la última pulsación y envía la cantidad final.