Développe un panier e-commerce persistant avec React Router 7 : hook useCart, localStorage, synchro serveur Prisma, API REST et UI responsive prête pour Stripe.
avec React Router 7
Posez vos questions 24/7 à notre IA experte en React Router 7
Validez vos acquis avec des quiz personnalisés et un feedback instantané
Mettre en place un panier complet :
/api/cart
) reliée à Prisma.useCart
1import { useFetcher } from "react-router";2import { useLocalStorage } from "usehooks-ts";3import { useOptionalUser, useUserCart } from "~/root";4import type { getProducts } from "~/server/products.server";56type Product = Awaited<ReturnType<typeof getProducts>>[number];78type CartItem = { product: Product; quantity: number };9type Cart = { items: CartItem[] };1011export const useCart = () => {12const user = useOptionalUser();13const isAuthenticated = Boolean(user);14const serverCart = useUserCart(); // pré-rempli via root.loader15const fetcher = useFetcher();1617/* 1️⃣ état persistant – localStorage */18const [cart, setCart] = useLocalStorage<Cart>("cart", {19items: serverCart.items, // hydrate depuis la DB20});2122/* --- helpers internes ----------------------------------------------- */23const sync = (formData: FormData) => {24if (!isAuthenticated) return; // invité → local only25fetcher.submit(formData, { method: "POST", action: "/api/cart" });26};2728const mutate = (updater: (c: Cart) => Cart, fd: FormData) => {29setCart(updater); // update local30sync(fd); // puis serveur31};3233/* --- API publique ---------------------------------------------------- */34const addToCart = (p: Product, q = 1) => {35mutate(36(c) => {37const i = c.items.findIndex((it) => it.product.id === p.id);38if (i >= 0) c.items[i].quantity += q;39else c.items.push({ product: p, quantity: q });40return { ...c };41},42new FormData([43["intent", "add-to-cart"],44["productId", p.id],45["quantity", String(q)],46]),47);48};4950const updateQuantity = (id: string, q: number) => {51mutate(52(c) => ({53items: c.items54.map((it) => (it.product.id === id ? { ...it, quantity: q } : it))55.filter((it) => it.quantity > 0),56}),57new FormData([58["intent", "update-quantity"],59["productId", id],60["quantity", String(q)],61]),62);63};6465const removeFromCart = (id: string) =>66mutate(67(c) => ({ items: c.items.filter((it) => it.product.id !== id) }),68new FormData([69["intent", "remove-from-cart"],70["productId", id],71]),72);7374const clearCart = () =>75mutate(76() => ({ items: [] }),77new FormData([["intent", "clear-cart"]]),78);7980/* --- getters --------------------------------------------------------- */81const getTotalPrice = () =>82cart.items.reduce((t, it) => t + it.product.priceCents * it.quantity, 0);8384const getTotalItems = () =>85cart.items.reduce((t, it) => t + it.quantity, 0);8687return {88cart,89addToCart,90updateQuantity,91removeFromCart,92clearCart,93getTotalPrice,94getTotalItems,95};96};
Pourquoi ?
useLocalStorage
persiste le panier sans écrire une seule ligne de useEffect
.CartProvider
1import { createContext, useContext } from "react";2import { useCart } from "~/hooks/use-cart";34type CartCtx = ReturnType<typeof useCart>;5const CartContext = createContext<CartCtx | null>(null);67export function CartProvider({ children }: { children: React.ReactNode }) {8const value = useCart();9return <CartContext.Provider value={value}>{children}</CartContext.Provider>;10}1112export const useCartContext = () => {13const ctx = useContext(CartContext);14if (!ctx) throw new Error("useCartContext must be used inside CartProvider");15return ctx;16};
Ajoute <CartProvider>
autour de l’<Outlet />
dans root.tsx
.
Toutes les pages peuvent maintenant lire / modifier le panier via useCartContext()
.
/api/cart
1import { z } from "zod";2import { parseWithZod, badRequest } from "@conform-to/zod";3import {4addToCart,5removeFromCart,6updateCartQuantity,7clearCart,8} from "~/server/cart.server";9import { getOptionalUser } from "~/server/auth.server";1011const Schema = z.discriminatedUnion("intent", [12z.object({13intent: z.literal("add-to-cart"),14productId: z.string(),15quantity: z.coerce.number().int().positive(),16}),17z.object({18intent: z.literal("remove-from-cart"),19productId: z.string(),20}),21z.object({22intent: z.literal("update-quantity"),23productId: z.string(),24quantity: z.coerce.number().int().min(0),25}),26z.object({ intent: z.literal("clear-cart") }),27]);2829export async function action({ request }) {30const user = await getOptionalUser(request);31if (!user) return new Response("Unauthorized", { status: 401 });3233const fd = await request.formData();34const submission = parseWithZod(fd, { schema: Schema });35if (submission.status !== "success")36return badRequest(submission.reply());3738const { intent } = submission.value;39switch (intent) {40case "add-to-cart":41await addToCart({ userId: user.id, ...submission.value });42break;43case "remove-from-cart":44await removeFromCart({ userId: user.id, ...submission.value });45break;46case "update-quantity":47await updateCartQuantity({ userId: user.id, ...submission.value });48break;49case "clear-cart":50await clearCart({ userId: user.id });51break;52}53return new Response("ok");54}
Explications :
server/cart.server.ts
) gèrent stock, upsert, relations.1export async function loader({ request }: Route.LoaderArgs) {2const user = await getOptionalUser(request);3const userCart = user ? await getUserCart(user.id) : { items: [] };4return data({ user, userCart });5}
Le hook useCart
utilise userCart.items
comme valeur initiale :
si le visiteur change d’appareil, son panier DB est ré-injecté dans le localStorage.
/cart
1<div className="flex items-center gap-2">2<Button3variant="outline"4size="sm"5onClick={() => updateQuantity(id, quantity - 1)}6disabled={quantity === 1}7>8<Minus className="size-3" />9</Button>10<span className="w-8 text-center">{quantity}</span>11<Button12variant="outline"13size="sm"14onClick={() => updateQuantity(id, quantity + 1)}15>16<Plus className="size-3" />17</Button>18</div>
Pourquoi ces choix ?
<input number>
: UX mobile pénible ; deux boutons suffisent.getTotalPrice()
) est fait côté client : instantané.clearCart()
vide localStorage et base (si connecté).1<Link to="/cart" className="relative p-2">2<ShoppingCart className="size-5" />3{getTotalItems() > 0 && (4<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center5justify-center rounded-full bg-red-600 text-[10px] text-white">6{getTotalItems()}7</span>8)}9</Link>
getTotalItems()
réagit instantanément au setCart()
du hook.Bug | Fix appliqué |
---|---|
Bouton « Ajouter » cliqué → carte navigue | e.preventDefault() sur le bouton, lien invisible reste fonctionnel |
Dropdown panier se fermait au survol | remplacé par composant Popover shadcn/ui |
Boucle infinie : fetch every 1 s | suppression du useEffect de polling, sync déclenchée uniquement sur mutation |
Conflit stock / quantité | contrôle Prisma dans addToCart + message d’erreur 400 |
createOrder()
).Tu as désormais toutes les fondations d’un vrai panier e-commerce !
Quelle est la principale différence entre les composants client et serveur dans React ?
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Quel hook permet de gérer les effets de bord dans un composant React ?
Comment implémenter la gestion des erreurs pour les requêtes API dans React ?
Quelle est la meilleure pratique pour déployer une application React en production ?