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.
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
- Avoir suivi la leçon « Structurer un projet Remix pour l’e-commerce »
- Connaître les hooks React (
useContext,useReducer,useEffect) - Avoir installé les dépendances de base (React Router, Tailwind CSS, Socket.io, Stripe)
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.
1import React, {2createContext,3ReactNode,4useContext,5useReducer,6useEffect,7} from "react";89export interface CartProduct {10documentId: string;11quantity: number;12}1314interface CartState {15products: CartProduct[];16}1718type Action =19| { type: "ADD"; product: CartProduct }20| { type: "REMOVE"; documentId: string }21| { type: "CLEAR" };2223const initialState: CartState = {24products: [],25};2627function cartReducer(state: CartState, action: Action): CartState {28switch (action.type) {29case "ADD": {30const idx = state.products.findIndex(31(p) => p.documentId === action.product.documentId,32);33let products: CartProduct[];34if (idx > -1) {35products = state.products.map((p, i) =>36i === idx37? { ...p, quantity: p.quantity + action.product.quantity }38: p,39);40} else {41products = [...state.products, action.product];42}43return { products };44}45case "REMOVE":46return {47products: state.products.filter(48(p) => p.documentId !== action.documentId,49),50};51case "CLEAR":52return initialState;53default:54return state;55}56}5758interface CartContextValue {59state: CartState;60dispatch: React.Dispatch<Action>;61}6263const CartContext = createContext<CartContextValue | undefined>(undefined);6465export function CartProvider({ children }: { children: ReactNode }) {66const [state, dispatch] = useReducer(cartReducer, initialState, (init) => {67try {68const stored = localStorage.getItem("cart");69return stored ? JSON.parse(stored) : init;70} catch {71return init;72}73});7475useEffect(() => {76localStorage.setItem("cart", JSON.stringify(state));77}, [state]);7879return (80<CartContext.Provider value={{ state, dispatch }}>81{children}82</CartContext.Provider>83);84}8586export function useCart() {87const ctx = useContext(CartContext);88if (!ctx) {89throw new Error("useCart must be used within CartProvider");90}91const { state, dispatch } = ctx;92const calculatedPrice = state.products.reduce(93(acc, cur) => {94// Ici, on simulera le prix côté client : 10€ par article95const price = 10 * cur.quantity;96return {97products: [...acc.products, { ...cur, price }],98totalPrice: acc.totalPrice + price,99};100},101{ products: [] as (CartProduct & { price: number })[], totalPrice: 0 },102);103104const addToCart = (p: CartProduct) =>105dispatch({ type: "ADD", product: p });106const removeFromCart = (id: string) =>107dispatch({ type: "REMOVE", documentId: id });108const clearCart = () => dispatch({ type: "CLEAR" });109110return { ...calculatedPrice, addToCart, removeFromCart, clearCart };111}
Explications clés
- On initialise le panier depuis
localStoragepour la persistance. - Le reducer gère 3 actions : ajouter, retirer, vider.
useCartexposecalculatedPrice(array + total) et des méthodes d’édition.
Persisting state
On utilise un initializer dans useReducer pour hydrater
immédiatement l’état depuis localStorage.
2. Enregistrer le provider dans Root
Il faut maintenant entourer ton application Remix par le CartProvider.
1import { CartProvider } from "./cart.context";23export default function App() {4return (5<CartProvider>6<Outlet /> // ≈ tes routes Remix7</CartProvider>8);9}
Tip
Vérifie que tu n’appelles pas useCart en dehors de ce provider : sinon
l’erreur te le rappellera immédiatement.
3. Utiliser le panier dans le composant Cart
On réutilise le contexte dans app/routes/cart.tsx (ou le composant <Cart />).
1import { Form, Link, useActionData, useNavigation } from "@remix-run/react";2import { useEffect } from "react";3import { useCart } from "~/cart.context";4import { useSocket } from "~/socket.context";5import {6getFormProps,7getInputProps,8useForm,9} from "@conform-to/react";10import { parseWithZod, getZodConstraint } from "@conform-to/zod";11import { OrderFormSchema } from "~/schemas/order"; // voir leçon commande
1export default function Cart() {2const { socket } = useSocket();3const { products, totalPrice, clearCart } = useCart();45useEffect(() => {6if (!socket) return;7socket.on("checkout", (sessionUrl: string) => {8clearCart();9window.location.href = sessionUrl;10});11}, [socket, clearCart]);1213if (products.length === 0) {14return (15<div className="p-6 text-center">16<p>Ton panier est vide !</p>17<Link to="/products" className="btn">18Voir les produits19</Link>20</div>21);22}2324const actionData = useActionData();25const [form, fields] = useForm({26lastResult: actionData?.result,27constraint: getZodConstraint(OrderFormSchema),28onValidate({ formData }) {29return parseWithZod(formData, { schema: OrderFormSchema });30},31defaultValue: {32products: products.map((p) => ({33documentId: p.documentId,34quantity: p.quantity,35})),36},37});38const nav = useNavigation();39const isLoading = nav.state !== "idle";4041return (42<Form {...getFormProps(form)} method="POST">43<div className="space-y-4 p-6">44{fields.products.getFieldList().map((field, i) => (45<CartItem46key={field.key}47field={field}48index={i}49allProducts={products}50/>51))}52<div className="text-right font-bold">53Total : {totalPrice}€54</div>55<button56type="submit"57disabled={isLoading}58className="btn-primary"59>60Commander61</button>62</div>63</Form>64);65}
// @callout: Le formulaire utilise @conform-to pour valider côté client et serveur.
Le composant CartItem
1import { getInputProps } from "@conform-to/react";2import { useCart } from "~/cart.context";34export function CartItem({5field,6allProducts,7index,8}: {9field: any;10allProducts: { documentId: string; price: number }[];11index: number;12}) {13const { removeFromCart } = useCart();14const docId = field.value.documentId;15const prod = allProducts.find((p) => p.documentId === docId);16return (17<div className="flex items-center justify-between">18<span>{prod?.documentId}</span>19<input20{...getInputProps(field.quantity, { type: "number" })}21className="w-16 border px-2"22/>23<button24onClick={() => removeFromCart(docId)}25type="button"26className="btn-danger"27>28Supprimer29</button>30</div>31);32}
4. Points clés
- React Context +
useReducerte donnent un store léger pour le panier. localStorageassure la persistance de l’état entre les rafraîchissements.- Enveloppe ton
<Outlet />dans le provider pour queuseCartsoit disponible. - Le composant
<Cart />récupèreproductsettotalPricepour 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
-
Ajouter la suppression partielle :
ModifieCartItempour que la quantité0supprime automatiquement l’article du panier. -
Persistance conditionnelle :
Ne stocke danslocalStorageque si le panier contient au moins un produit. -
Calcul en temps réel :
Ajoute dansCartProviderun calcul du nombre total d’articles (totalItems) à exposer viauseCart().