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.

6 min read
Déverrouillez votre potentiel

avec React Router 7

Vous en avez marre de...

❌ perdre du temps à chercher des informations éparpillées
❌ ne pas avoir de retour sur votre progression
Assistant IA spécialisé

Posez vos questions 24/7 à notre IA experte en React Router 7

Quiz interactifs

Validez vos acquis avec des quiz personnalisés et un feedback instantané

9 modules
72 leçons
Accès à vie
299.49
-35%

Objectif de la leçon

Mettre en place un panier complet :

  1. stockage persistant côté navigateur (localStorage) ;
  2. synchronisation automatique côté serveur pour les utilisateurs connectés ;
  3. UI responsive (navbar + page /cart) ;
  4. API REST sécurisée (/api/cart) reliée à Prisma.

Étape 1 – créer le hook useCart

app/hooks/use-cart.ts
1
import { useFetcher } from "react-router";
2
import { useLocalStorage } from "usehooks-ts";
3
import { useOptionalUser, useUserCart } from "~/root";
4
import type { getProducts } from "~/server/products.server";
5
6
type Product = Awaited<ReturnType<typeof getProducts>>[number];
7
8
type CartItem = { product: Product; quantity: number };
9
type Cart = { items: CartItem[] };
10
11
export const useCart = () => {
12
const user = useOptionalUser();
13
const isAuthenticated = Boolean(user);
14
const serverCart = useUserCart(); // pré-rempli via root.loader
15
const fetcher = useFetcher();
16
17
/* 1️⃣ état persistant – localStorage */
18
const [cart, setCart] = useLocalStorage<Cart>("cart", {
19
items: serverCart.items, // hydrate depuis la DB
20
});
21
22
/* --- helpers internes ----------------------------------------------- */
23
const sync = (formData: FormData) => {
24
if (!isAuthenticated) return; // invité → local only
25
fetcher.submit(formData, { method: "POST", action: "/api/cart" });
26
};
27
28
const mutate = (updater: (c: Cart) => Cart, fd: FormData) => {
29
setCart(updater); // update local
30
sync(fd); // puis serveur
31
};
32
33
/* --- API publique ---------------------------------------------------- */
34
const addToCart = (p: Product, q = 1) => {
35
mutate(
36
(c) => {
37
const i = c.items.findIndex((it) => it.product.id === p.id);
38
if (i >= 0) c.items[i].quantity += q;
39
else c.items.push({ product: p, quantity: q });
40
return { ...c };
41
},
42
new FormData([
43
["intent", "add-to-cart"],
44
["productId", p.id],
45
["quantity", String(q)],
46
]),
47
);
48
};
49
50
const updateQuantity = (id: string, q: number) => {
51
mutate(
52
(c) => ({
53
items: c.items
54
.map((it) => (it.product.id === id ? { ...it, quantity: q } : it))
55
.filter((it) => it.quantity > 0),
56
}),
57
new FormData([
58
["intent", "update-quantity"],
59
["productId", id],
60
["quantity", String(q)],
61
]),
62
);
63
};
64
65
const removeFromCart = (id: string) =>
66
mutate(
67
(c) => ({ items: c.items.filter((it) => it.product.id !== id) }),
68
new FormData([
69
["intent", "remove-from-cart"],
70
["productId", id],
71
]),
72
);
73
74
const clearCart = () =>
75
mutate(
76
() => ({ items: [] }),
77
new FormData([["intent", "clear-cart"]]),
78
);
79
80
/* --- getters --------------------------------------------------------- */
81
const getTotalPrice = () =>
82
cart.items.reduce((t, it) => t + it.product.priceCents * it.quantity, 0);
83
84
const getTotalItems = () =>
85
cart.items.reduce((t, it) => t + it.quantity, 0);
86
87
return {
88
cart,
89
addToCart,
90
updateQuantity,
91
removeFromCart,
92
clearCart,
93
getTotalPrice,
94
getTotalItems,
95
};
96
};

Pourquoi ?

  • useLocalStorage persiste le panier sans écrire une seule ligne de useEffect.
  • 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

app/contexts/cart-context.tsx
1
import { createContext, useContext } from "react";
2
import { useCart } from "~/hooks/use-cart";
3
4
type CartCtx = ReturnType<typeof useCart>;
5
const CartContext = createContext<CartCtx | null>(null);
6
7
export function CartProvider({ children }: { children: React.ReactNode }) {
8
const value = useCart();
9
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
10
}
11
12
export const useCartContext = () => {
13
const ctx = useContext(CartContext);
14
if (!ctx) throw new Error("useCartContext must be used inside CartProvider");
15
return 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

api.cart.ts
1
import { z } from "zod";
2
import { parseWithZod, badRequest } from "@conform-to/zod";
3
import {
4
addToCart,
5
removeFromCart,
6
updateCartQuantity,
7
clearCart,
8
} from "~/server/cart.server";
9
import { getOptionalUser } from "~/server/auth.server";
10
11
const Schema = z.discriminatedUnion("intent", [
12
z.object({
13
intent: z.literal("add-to-cart"),
14
productId: z.string(),
15
quantity: z.coerce.number().int().positive(),
16
}),
17
z.object({
18
intent: z.literal("remove-from-cart"),
19
productId: z.string(),
20
}),
21
z.object({
22
intent: z.literal("update-quantity"),
23
productId: z.string(),
24
quantity: z.coerce.number().int().min(0),
25
}),
26
z.object({ intent: z.literal("clear-cart") }),
27
]);
28
29
export async function action({ request }) {
30
const user = await getOptionalUser(request);
31
if (!user) return new Response("Unauthorized", { status: 401 });
32
33
const fd = await request.formData();
34
const submission = parseWithZod(fd, { schema: Schema });
35
if (submission.status !== "success")
36
return badRequest(submission.reply());
37
38
const { intent } = submission.value;
39
switch (intent) {
40
case "add-to-cart":
41
await addToCart({ userId: user.id, ...submission.value });
42
break;
43
case "remove-from-cart":
44
await removeFromCart({ userId: user.id, ...submission.value });
45
break;
46
case "update-quantity":
47
await updateCartQuantity({ userId: user.id, ...submission.value });
48
break;
49
case "clear-cart":
50
await clearCart({ userId: user.id });
51
break;
52
}
53
return 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

app/root.tsx {24-30}
1
export async function loader({ request }: Route.LoaderArgs) {
2
const user = await getOptionalUser(request);
3
const userCart = user ? await getUserCart(user.id) : { items: [] };
4
return 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

app/routes/_public+/cart.tsx {48-66}
1
<div className="flex items-center gap-2">
2
<Button
3
variant="outline"
4
size="sm"
5
onClick={() => updateQuantity(id, quantity - 1)}
6
disabled={quantity === 1}
7
>
8
<Minus className="size-3" />
9
</Button>
10
<span className="w-8 text-center">{quantity}</span>
11
<Button
12
variant="outline"
13
size="sm"
14
onClick={() => 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

app/components/layout/navbar.tsx {18-26}
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-center
5
justify-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 au setCart() du hook.

Problèmes rencontrés & correctifs

BugFix appliqué
Bouton « Ajouter » cliqué → carte naviguee.preventDefault() sur le bouton, lien invisible reste fonctionnel
Dropdown panier se fermait au survolremplacé par composant Popover shadcn/ui
Boucle infinie : fetch every 1 ssuppression 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

  1. Création de la commande + paiement Stripe (hook createOrder()).
  2. Web-hook Stripe → email de confirmation Resend.
  3. UI optimiste sur le paiement + redirection « Merci ».

Tu as désormais toutes les fondations d’un vrai panier e-commerce !

Premium
Quiz interactif
Testez vos connaissances et validez votre compréhension du module avec notre quiz interactif.
1

Comprendre les concepts fondamentaux

Quelle est la principale différence entre les composants client et serveur dans React ?

Les composants client s'exécutent uniquement dans le navigateur
Les composants serveur peuvent utiliser useState
Les composants client sont plus rapides
Il n'y a aucune différence significative
2

Optimisation des performances

Quelle technique est recommandée pour éviter les rendus inutiles dans React ?

Utiliser React.memo pour les composants fonctionnels
Ajouter plus d'états locaux
Éviter d'utiliser les props
Toujours utiliser les class components
3

Architecture des données

Quel hook permet de gérer les effets de bord dans un composant React ?

useEffect
useState
useMemo
useContext
4

Gestion des erreurs

Comment implémenter la gestion des erreurs pour les requêtes API dans React ?

Utiliser try/catch avec async/await
Ignorer les erreurs
Toujours afficher un message d'erreur
Rediriger l'utilisateur
5

Déploiement et CI/CD

Quelle est la meilleure pratique pour déployer une application React en production ?

Utiliser un service CI/CD comme GitHub Actions
Copier les fichiers manuellement via FTP
Envoyer le code source complet
Ne jamais mettre à jour l'application

Débloquez ce quiz et tous les autres contenus premium en achetant ce cours