Mini-Projet E-commerce

Tutoriel étape par étape — Semaine 5

Zustand

Panier

Axios

API Client

React Query

Server State

Forms + Zod

Validation

Ce qu'on va construire

🛒 Boutique en ligne

Liste de produits avec détail

🛍️ Panier interactif

Ajout, suppression, quantités

📝 Formulaire de commande

Validation complète avec Zod

✅ Confirmation

Création de commande

🎯 Objectif pédagogique : Voir comment tous les outils travaillent ensemble

Aperçu du projet final

Page Produits

• Liste des produits

• Filtres par catégorie

• Détail du produit

Panier

• Ajouter/retirer

• Modifier quantité

• Total calculé

Checkout

• Formulaire validé

• Récapitulatif

• Confirmation

Commençons par la structure du projet !

Architecture Feature-Based

Un dossier par domaine métier

Chaque feature contient ses propres :

components, hooks, services, types, schemas

Structure des dossiers

📁 src/

📁 features/

📁 products/

📁 components/

📁 hooks/

📁 services/

📄 types.ts

📁 cart/

📁 components/

📄 store.ts

📄 types.ts

📁 checkout/

📁 components/

📁 hooks/

📄 schemas.ts

📁 lib/

📄 api.ts

💡 Chaque feature est autonome et peut être développée indépendamment

Installation des dépendances

# Toutes les dépendances de la semaine 5

npm install zustand @tanstack/react-query axios

npm install react-hook-form zod @hookform/resolvers

zustand

State management

@tanstack/react-query

Server state

axios

HTTP client

react-hook-form + zod

Formulaires + validation

Configuration de l'API Client

lib/api.ts

import axios from 'axios';

export const api = axios.create({

baseURL: 'https://fakestoreapi.com',

timeout: 5000,

headers: {

'Content-Type': 'application/json'

}

});

💡 On utilise FakeStore API pour les données de démonstration

Feature: Products

React Query pour le server state

useQuery

Fetch les produits

Query Keys

Cache intelligent

Types TypeScript

features/products/types.ts

export interface Product {

id: number;

title: string;

price: number;

description: string;

category: string;

image: string;

rating: {

rate: number;

count: number;

};

}

API Services

features/products/services/productService.ts

import { api } from '@/lib/api';

import type { Product } from '../types';

export const getProducts = async (): Promise<Product[]> => {

const { data } = await api.get('/products');

return data;

};

export const getProduct = async (id: number): Promise<Product> => {

const { data } = await api.get(`/products/${id}`);

return data;

};

Query Keys Factory

features/products/hooks/productKeys.ts

export const productKeys = {

all: ['products'] as const,

lists: () => [...productKeys.all, 'list'] as const,

list: (filter: string) => [...productKeys.lists(), filter] as const,

details: () => [...productKeys.all, 'detail'] as const,

detail: (id: number) => [...productKeys.details(), id] as const,

};

💡 Pattern key factory = autocomplétion + pas de typo + refactoring facile

useProducts Hook

features/products/hooks/useProducts.ts

import { useQuery } from '@tanstack/react-query';

import { getProducts } from '../services/productService';

import { productKeys } from './productKeys';

export const useProducts = () => {

return useQuery({

queryKey: productKeys.lists(),

queryFn: getProducts,

staleTime: 5 * 60 * 1000 // 5 minutes

});

};

✅ Retourne: { data, isLoading, isError, error }

useProduct Hook (détail)

features/products/hooks/useProduct.ts

export const useProduct = (id: number) => {

return useQuery({

queryKey: productKeys.detail(id),

queryFn: () => getProduct(id),

enabled: !!id // Ne fetch que si id existe

});

};

💡 enabled empêche le fetch si id est undefined

ProductList Component

import { useProducts } from '../hooks/useProducts';

import { ProductCard } from './ProductCard';

export function ProductList() {

const { data: products, isLoading, isError } = useProducts();

if (isLoading) return <div>Chargement...</div>;

if (isError) return <div>Erreur de chargement</div>;

return (

<div className="grid grid-cols-4 gap-4">

{products.map(product => (

<ProductCard key={product.id} product={product} />

))}

</div>

);

}

✅ 3 états gérés automatiquement: loading, error, success

ProductCard Component

import { useCartStore } from '@/features/cart/store';

export function ProductCard({ product }: { product: Product }) {

const addToCart = useCartStore(state => state.addToCart);

return (

<div className="border rounded-lg p-4">

<img src={product.image} alt={product.title} />

<h3>{product.title}</h3>

<p>{product.price} €</p>

<button onClick={() => addToCart(product)}>

Ajouter au panier

</button>

</div>

);

}

🔗 Note: On utilise déjà le store Zustand pour le panier!

Feature: Cart

Zustand pour le client state

Pourquoi Zustand pour le panier ?

• Persiste entre les pages

• Pas de re-fetch nécessaire

• UI state pur (pas de serveur)

Types du panier

features/cart/types.ts

import type { Product } from '@/features/products/types';

export interface CartItem {

product: Product;

quantity: number;

}

export interface CartState {

items: CartItem[];

totalItems: number;

totalPrice: number;

}

Création du Store

features/cart/store.ts (partie 1)

import { create } from 'zustand';

import { persist } from 'zustand/middleware';

interface CartStore extends CartState {

addToCart: (product: Product) => void;

removeFromCart: (productId: number) => void;

updateQuantity: (productId: number, quantity: number) => void;

clearCart: () => void;

}

Store Implementation

features/cart/store.ts (partie 2)

export const useCartStore = create<CartStore>()

.persist(

(set, get) => ({

items: [],

totalItems: 0,

totalPrice: 0,

addToCart: (product) => set((state) => {

const existing = state.items.find(i => i.product.id === product.id);

if (existing) {

existing.quantity++;

} else {

state.items.push({ product, quantity: 1 });

}

return state;

}),

// ... autres actions

}),

{ name: 'cart-storage' }

);

💾 persist() sauvegarde le panier dans localStorage

Actions: Remove & Clear

removeFromCart: (productId) => set((state) => ({

items: state.items.filter(i => i.product.id !== productId)

})),

updateQuantity: (productId, quantity) => set((state) => ({

items: state.items.map(i =>

i.product.id === productId

? { ...i, quantity }

: i

)

})),

clearCart: () => set({ items: [], totalItems: 0, totalPrice: 0 }),

Selectors optimisés

// Calculer le total avec un selector

export const useCartTotal = () =>

useCartStore((state) =>

state.items.reduce((sum, item) =>

sum + item.product.price * item.quantity,

0

)

);

// Compter les articles

export const useCartCount = () =>

useCartStore((state) =>

state.items.reduce((sum, item) => sum + item.quantity, 0)

);

✅ Les selectors évitent les re-renders inutiles

CartWidget Component

import { useCartCount } from '../store';

export function CartWidget() {

const count = useCartCount();

return (

<button className="relative">

🛒

{count > 0 && (

<span className="badge">{count}</span>

)}

</button>

);

}

💡 Re-render SEULEMENT si le count change

CartDrawer Component

export function CartDrawer({ isOpen, onClose }) {

const { items, removeFromCart, updateQuantity } = useCartStore();

const total = useCartTotal();

return (

<div className={isOpen ? 'open' : 'closed'}>

{items.map(item => (

<CartItemRow

key={item.product.id}

item={item}

onRemove={() => removeFromCart(item.product.id)}

onQuantityChange={(q) => updateQuantity(item.product.id, q)}

/>

))}

<div>Total: {total} €</div>

<Link to="/checkout">Commander</Link>

</div>

);

}

Feature: Checkout

React Hook Form + Zod

useForm

Gestion du formulaire

zodResolver

Validation

Schéma de validation Zod

features/checkout/schemas.ts

import { z } from 'zod';

export const checkoutSchema = z.object({

firstName: z.string().min(2, "Min 2 caractères"),

lastName: z.string().min(2, "Min 2 caractères"),

email: z.string().email("Email invalide"),

address: z.string().min(5, "Adresse requise"),

city: z.string().min(2, "Ville requise"),

postalCode: z.string().regex(/^\d{5}$/, "Code postal invalide"),

});

Inférence du type

// Le schéma génère automatiquement le type!

export type CheckoutFormData = z.infer<typeof checkoutSchema>;

// Équivalent à :

// type CheckoutFormData = {

// firstName: string;

// lastName: string;

// email: string;

// address: string;

// city: string;

// postalCode: string;

// }

🎯 Un seul schéma = Type TypeScript + Validation runtime

Configuration du formulaire

features/checkout/components/CheckoutForm.tsx

import { useForm } from 'react-hook-form';

import { zodResolver } from '@hookform/resolvers/zod';

import { checkoutSchema, type CheckoutFormData } from '../schemas';

export function CheckoutForm() {

const {

register,

handleSubmit,

formState: { errors, isSubmitting }

} = useForm<CheckoutFormData>({

resolver: zodResolver(checkoutSchema),

});

// ...

}

Champs du formulaire

return (

<form onSubmit={handleSubmit(onSubmit)}>

<div>

<label>Prénom</label>

<input {...register('firstName')} />

{errors.firstName && (

<span className="error">{errors.firstName.message}</span>

)}

</div>

// ... autres champs

<button type="submit" disabled={isSubmitting}>

{isSubmitting ? 'Envoi...' : 'Commander'}

</button>

</form>

);

Soumission du formulaire

const onSubmit = async (data: CheckoutFormData) => {

const orderData = {

customer: data,

items: useCartStore.getState().items,

total: useCartStore.getState().items.reduce(...)

};

await createOrder(orderData);

useCartStore.getState().clearCart();

navigate('/confirmation');

};

🔗 Intégration: On lit le panier depuis Zustand et on vide après commande

Récapitulatif de commande

OrderSummary Component

• Affiche les articles

• Calcule sous-total

• Applique taxes/frais

• Affiche total final

Données utilisées

Zustand: items du panier

React Hook Form: données client

Calcul local: totaux

UX de validation

✅ Validation en temps réel

Les erreurs s'affichent dès que l'utilisateur quitte le champ (onBlur)

✅ Messages personnalisés

Définis dans le schéma Zod, clairs pour l'utilisateur

✅ État isSubmitting

Désactive le bouton pendant l'envoi, évite les doubles soumissions

Feature: Orders

Mutations React Query

useMutation

Créer une commande

invalidateQueries

Rafraîchir le cache

API Service pour les commandes

features/orders/services/orderService.ts

import { api } from '@/lib/api';

export interface CreateOrderInput {

customer: CheckoutFormData;

items: CartItem[];

total: number;

}

export const createOrder = async (data: CreateOrderInput) => {

const { data: order } = await api.post('/orders', data);

return order;

};

useCreateOrder Hook

import { useMutation, useQueryClient } from '@tanstack/react-query';

import { createOrder } from '../services/orderService';

export const useCreateOrder = () => {

const queryClient = useQueryClient();

return useMutation({

mutationFn: createOrder,

onSuccess: (data) => {

queryClient.invalidateQueries({ queryKey: ['orders'] });

queryClient.setQueryData(['orders', data.id], data);

},

});

};

Pattern d'invalidation

invalidateQueries()

Marque les données comme "stale"

Déclenche un refetch automatique

Si un composant utilise ces données

setQueryData()

Met à jour le cache directement

Évite un refetch inutile

Optimistic update possible

💡 Combine les deux pour une UX optimale

Gestion des erreurs

const { mutate, isError, error } = useCreateOrder();

const handleSubmit = (data: CheckoutFormData) => {

mutate(orderData, {

onError: (error) => {

toast.error(`Erreur: ${error.message}`);

},

onSuccess: (data) => {

toast.success('Commande confirmée!');

navigate(`/orders/${data.id}`);

}

});

};

Page de confirmation

export function OrderConfirmation() {

const { id } = useParams();

const { data: order, isLoading } = useOrder(Number(id));

if (isLoading) return <Spinner />;

return (

<div className="confirmation">

<h1>Merci pour votre commande!</h1>

<p>Commande #{order.id}</p>

<OrderSummary order={order} />

</div>

);

}

Architecture Complète

Comment tout fonctionne ensemble

Zustand

Panier (UI)

React Query

Produits (API)

Forms

Checkout

Axios

HTTP

Flux de données

1. Produits

React Query fetch depuis API → Affichage

2. Panier

Click "Ajouter" → Zustand store update → UI sync

3. Checkout

Formulaire validé via Zod → Données prêtes

4. Commande

Mutation React Query → API → Confirmation

Responsabilités de chaque outil

Zustand

Client State

  • ✅ État de l'UI (panier ouvert)
  • ✅ Données temporaires (panier)
  • ✅ Préférences utilisateur
  • ❌ PAS de données serveur

React Query

Server State

  • ✅ Données du serveur
  • ✅ Cache automatique
  • ✅ Refetch intelligent
  • ✅ Mutations CRUD

React Hook Form

Formulaires

  • ✅ Gestion des inputs
  • ✅ État du formulaire
  • ✅ Soumission
  • ✅ Performance (refs)

Zod

Validation

  • ✅ Schémas de validation
  • ✅ Types TypeScript
  • ✅ Messages d'erreur
  • ✅ Runtime safety

Points clés à retenir

1

Séparez client state (Zustand) et server state (React Query)

2

Utilisez query keys factory pour un cache organisé

3

Invalidate le cache après les mutations

4

Un schéma Zod = Type + Validation

5

Organisez par feature, pas par type de fichier

Ressources

Zustand

zustand-demo.pmnd.rs

React Query

tanstack.com/query

React Hook Form

react-hook-form.com

Zod

zod.dev

🎉 Félicitations!

Vous maîtrisez maintenant le stack complet de la semaine 5!