Créer un panier d’achat dynamique avec React Context

Gérez l’ajout, la suppression et la persistance des produits dans le panier côté client.

6 min read

Introduction

Dans ce chapitre, tu vas apprendre à créer un panier d’achat dynamique grâce à React Context dans une app Remix.
Le contexte global du panier te permet de :

  • Partager l’état du panier entre tes pages sans props drilling
  • Mettre à jour les quantités, ajouter ou retirer un produit
  • Conserver le panier en localStorage pour la persistance

On intégrera ce contexte dans Remix, on le testera dans un composant <Cart /> enrichi avec un formulaire de commande et Socket.io pour Stripe.

Prérequis


1. Créer le contexte du panier

Commence par créer un fichier app/cart.context.tsx qui définit le provider et le hook useCart.

app/cart.context.tsx
1
import React, {
2
createContext,
3
ReactNode,
4
useContext,
5
useReducer,
6
useEffect,
7
} from "react";
8
9
export interface CartProduct {
10
documentId: string;
11
quantity: number;
12
}
13
14
interface CartState {
15
products: CartProduct[];
16
}
17
18
type Action =
19
| { type: "ADD"; product: CartProduct }
20
| { type: "REMOVE"; documentId: string }
21
| { type: "CLEAR" };
22
23
const initialState: CartState = {
24
products: [],
25
};
26
27
function cartReducer(state: CartState, action: Action): CartState {
28
switch (action.type) {
29
case "ADD": {
30
const idx = state.products.findIndex(
31
(p) => p.documentId === action.product.documentId,
32
);
33
let products: CartProduct[];
34
if (idx > -1) {
35
products = state.products.map((p, i) =>
36
i === idx
37
? { ...p, quantity: p.quantity + action.product.quantity }
38
: p,
39
);
40
} else {
41
products = [...state.products, action.product];
42
}
43
return { products };
44
}
45
case "REMOVE":
46
return {
47
products: state.products.filter(
48
(p) => p.documentId !== action.documentId,
49
),
50
};
51
case "CLEAR":
52
return initialState;
53
default:
54
return state;
55
}
56
}
57
58
interface CartContextValue {
59
state: CartState;
60
dispatch: React.Dispatch<Action>;
61
}
62
63
const CartContext = createContext<CartContextValue | undefined>(undefined);
64
65
export function CartProvider({ children }: { children: ReactNode }) {
66
const [state, dispatch] = useReducer(cartReducer, initialState, (init) => {
67
try {
68
const stored = localStorage.getItem("cart");
69
return stored ? JSON.parse(stored) : init;
70
} catch {
71
return init;
72
}
73
});
74
75
useEffect(() => {
76
localStorage.setItem("cart", JSON.stringify(state));
77
}, [state]);
78
79
return (
80
<CartContext.Provider value={{ state, dispatch }}>
81
{children}
82
</CartContext.Provider>
83
);
84
}
85
86
export function useCart() {
87
const ctx = useContext(CartContext);
88
if (!ctx) {
89
throw new Error("useCart must be used within CartProvider");
90
}
91
const { state, dispatch } = ctx;
92
const calculatedPrice = state.products.reduce(
93
(acc, cur) => {
94
// Ici, on simulera le prix côté client : 10€ par article
95
const price = 10 * cur.quantity;
96
return {
97
products: [...acc.products, { ...cur, price }],
98
totalPrice: acc.totalPrice + price,
99
};
100
},
101
{ products: [] as (CartProduct & { price: number })[], totalPrice: 0 },
102
);
103
104
const addToCart = (p: CartProduct) =>
105
dispatch({ type: "ADD", product: p });
106
const removeFromCart = (id: string) =>
107
dispatch({ type: "REMOVE", documentId: id });
108
const clearCart = () => dispatch({ type: "CLEAR" });
109
110
return { ...calculatedPrice, addToCart, removeFromCart, clearCart };
111
}

Explications clés

  • On initialise le panier depuis localStorage pour la persistance.
  • Le reducer gère 3 actions : ajouter, retirer, vider.
  • useCart expose calculatedPrice (array + total) et des méthodes d’édition.

2. Enregistrer le provider dans Root

Il faut maintenant entourer ton application Remix par le CartProvider.

app/root.tsx
1
import { CartProvider } from "./cart.context";
2
3
export default function App() {
4
return (
5
<CartProvider>
6
<Outlet /> // ≈ tes routes Remix
7
</CartProvider>
8
);
9
}

3. Utiliser le panier dans le composant Cart

On réutilise le contexte dans app/routes/cart.tsx (ou le composant <Cart />).

app/routes/cart.tsx
1
import { Form, Link, useActionData, useNavigation } from "@remix-run/react";
2
import { useEffect } from "react";
3
import { useCart } from "~/cart.context";
4
import { useSocket } from "~/socket.context";
5
import {
6
getFormProps,
7
getInputProps,
8
useForm,
9
} from "@conform-to/react";
10
import { parseWithZod, getZodConstraint } from "@conform-to/zod";
11
import { OrderFormSchema } from "~/schemas/order"; // voir leçon commande
app/routes/cart.tsx
1
export default function Cart() {
2
const { socket } = useSocket();
3
const { products, totalPrice, clearCart } = useCart();
4
5
useEffect(() => {
6
if (!socket) return;
7
socket.on("checkout", (sessionUrl: string) => {
8
clearCart();
9
window.location.href = sessionUrl;
10
});
11
}, [socket, clearCart]);
12
13
if (products.length === 0) {
14
return (
15
<div className="p-6 text-center">
16
<p>Ton panier est vide !</p>
17
<Link to="/products" className="btn">
18
Voir les produits
19
</Link>
20
</div>
21
);
22
}
23
24
const actionData = useActionData();
25
const [form, fields] = useForm({
26
lastResult: actionData?.result,
27
constraint: getZodConstraint(OrderFormSchema),
28
onValidate({ formData }) {
29
return parseWithZod(formData, { schema: OrderFormSchema });
30
},
31
defaultValue: {
32
products: products.map((p) => ({
33
documentId: p.documentId,
34
quantity: p.quantity,
35
})),
36
},
37
});
38
const nav = useNavigation();
39
const isLoading = nav.state !== "idle";
40
41
return (
42
<Form {...getFormProps(form)} method="POST">
43
<div className="space-y-4 p-6">
44
{fields.products.getFieldList().map((field, i) => (
45
<CartItem
46
key={field.key}
47
field={field}
48
index={i}
49
allProducts={products}
50
/>
51
))}
52
<div className="text-right font-bold">
53
Total : {totalPrice}
54
</div>
55
<button
56
type="submit"
57
disabled={isLoading}
58
className="btn-primary"
59
>
60
Commander
61
</button>
62
</div>
63
</Form>
64
);
65
}

// @callout: Le formulaire utilise @conform-to pour valider côté client et serveur.

Le composant CartItem

app/components/CartItem.tsx
1
import { getInputProps } from "@conform-to/react";
2
import { useCart } from "~/cart.context";
3
4
export function CartItem({
5
field,
6
allProducts,
7
index,
8
}: {
9
field: any;
10
allProducts: { documentId: string; price: number }[];
11
index: number;
12
}) {
13
const { removeFromCart } = useCart();
14
const docId = field.value.documentId;
15
const prod = allProducts.find((p) => p.documentId === docId);
16
return (
17
<div className="flex items-center justify-between">
18
<span>{prod?.documentId}</span>
19
<input
20
{...getInputProps(field.quantity, { type: "number" })}
21
className="w-16 border px-2"
22
/>
23
<button
24
onClick={() => removeFromCart(docId)}
25
type="button"
26
className="btn-danger"
27
>
28
Supprimer
29
</button>
30
</div>
31
);
32
}

4. Points clés

  • React Context + useReducer te donnent un store léger pour le panier.
  • localStorage assure la persistance de l’état entre les rafraîchissements.
  • Enveloppe ton <Outlet /> dans le provider pour que useCart soit disponible.
  • Le composant <Cart /> récupère products et totalPrice pour l’affichage.
  • L’intégration avec Stripe passe par Socket.io : dès que le backend envoie checkout, on redirige vers l’URL de paiement.

Exercices rapides

  1. Ajouter la suppression partielle :
    Modifie CartItem pour que la quantité 0 supprime automatiquement l’article du panier.

  2. Persistance conditionnelle :
    Ne stocke dans localStorage que si le panier contient au moins un produit.

  3. Calcul en temps réel :
    Ajoute dans CartProvider un calcul du nombre total d’articles (totalItems) à exposer via useCart().