Implémente un panier de site-ecommerce dans ton app React Router 7
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.
Objectif de la leçon
Mettre en place un panier complet :
- stockage persistant côté navigateur (localStorage) ;
- synchronisation automatique côté serveur pour les utilisateurs connectés ;
- UI responsive (navbar + page /cart) ;
- API REST sécurisée (
/api/cart) reliée à Prisma.
Étape 1 – créer le hook 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 ?
useLocalStoragepersiste le panier sans écrire une seule ligne deuseEffect.- Le fetcher ne s’active que si l’utilisateur est connecté, évitant des requêtes 401.
- Aucune boucle infinie : la synchro se déclenche uniquement lors d’une vraie mutation.
Étape 2 – exposer le contexte 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().
Étape 3 – API REST /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 :
- Zod + discriminated union = validation stricte d’un seul endpoint.
- Les fonctions Prisma (
server/cart.server.ts) gèrent stock, upsert, relations.
Étape 4 – root loader : hydrater le panier serveur
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.
Étape 5 – UI : page /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 ?
- Pas de
<input number>: UX mobile pénible ; deux boutons suffisent. - Le calcul du total (
getTotalPrice()) est fait côté client : instantané. clearCart()vide localStorage et base (si connecté).
Étape 6 – Navbar : badge quantité + dropdown
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>
- Affichée partout grâce au layout global.
getTotalItems()réagit instantanément ausetCart()du hook.
Problèmes rencontrés & correctifs
| 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 |
Résultat
- Panier persistant offline + online.
- Une seule source de vérité : localStorage quand l’utilisateur est Offline, DB quand il est connecté.
- API sécurisée, typée, réutilisable pour le futur checkout Stripe.
- UI responsive : badge quantité, page détaillée, actions rapides.
Prochaines étapes
- Création de la commande + paiement Stripe (hook
createOrder()). - Web-hook Stripe → email de confirmation Resend.
- UI optimiste sur le paiement + redirection « Merci ».
Tu as désormais toutes les fondations d’un vrai panier e-commerce !
Comprendre les concepts fondamentaux
Quelle est la principale différence entre les composants client et serveur dans React ?
Optimisation des performances
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Architecture des données
Quel hook permet de gérer les effets de bord dans un composant React ?