Gérez l’ajout, la suppression et la persistance des produits dans le panier côté client.
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 :
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.
useContext
, useReducer
, useEffect
)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}
localStorage
pour la persistance.useCart
expose calculatedPrice
(array + total) et des méthodes d’édition.On utilise un initializer dans useReducer
pour hydrater
immédiatement l’état depuis localStorage
.
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}
Vérifie que tu n’appelles pas useCart
en dehors de ce provider : sinon
l’erreur te le rappellera immédiatement.
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.
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}
useReducer
te donnent un store léger pour le panier.localStorage
assure la persistance de l’état entre les rafraîchissements.<Outlet />
dans le provider pour que useCart
soit disponible.<Cart />
récupère products
et totalPrice
pour l’affichage.checkout
,
on redirige vers l’URL de paiement.Ajouter la suppression partielle :
Modifie CartItem
pour que la quantité 0
supprime automatiquement l’article du panier.
Persistance conditionnelle :
Ne stocke dans localStorage
que si le panier contient au moins un produit.
Calcul en temps réel :
Ajoute dans CartProvider
un calcul du nombre total d’articles (totalItems
) à exposer via useCart()
.