Tutoriel étape par étape — Semaine 5
Zustand
Panier
Axios
API Client
React Query
Server State
Forms + Zod
Validation
🛒 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
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 !
Un dossier par domaine métier
Chaque feature contient ses propres :
components, hooks, services, types, schemas
📁 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
# 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
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
React Query pour le server state
useQuery
Fetch les produits
Query Keys
Cache intelligent
features/products/types.ts
export interface Product {
id: number;
title: string;
price: number;
description: string;
category: string;
image: string;
rating: {
rate: number;
count: number;
};
}
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;
};
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
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 }
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
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
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!
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)
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;
}
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;
}
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
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 }),
// 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
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
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>
);
}
React Hook Form + Zod
useForm
Gestion du formulaire
zodResolver
Validation
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"),
});
// 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
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),
});
// ...
}
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>
);
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
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
✅ 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
Mutations React Query
useMutation
Créer une commande
invalidateQueries
Rafraîchir le cache
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;
};
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);
},
});
};
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
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}`);
}
});
};
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>
);
}
Comment tout fonctionne ensemble
Zustand
Panier (UI)
React Query
Produits (API)
Forms
Checkout
Axios
HTTP
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
Zustand
Client State
React Query
Server State
React Hook Form
Formulaires
Zod
Validation
Séparez client state (Zustand) et server state (React Query)
Utilisez query keys factory pour un cache organisé
Invalidate le cache après les mutations
Un schéma Zod = Type + Validation
Organisez par feature, pas par type de fichier
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!