React State Management - Gérer l'état comme un pro
Quand ton app grandit, gérer l'état avec juste useState
devient vite le chaos. On va voir comment structurer et partager l'état efficacement.
C'est là qu'intervient les stores ( ou système de management globaux d'état )
Le problème de la gestion d'état
Qu'est-ce que l'état (state) ?
L'état, c'est toutes les données qui peuvent changer dans ton application :
- Données utilisateur (profil, préférences)
- État de l'interface (modales ouvertes, loading)
- Données métier (produits, commandes)
- Cache d'API
Les problèmes qui arrivent
Avec useState
seul, tu vas vite avoir ces problèmes :
// ❌ Problème : Prop Drilling
function App() {
const [user, setUser] = useState(null)
const [cart, setCart] = useState([])
const [theme, setTheme] = useState('light')
return (
<Header user={user} theme={theme} setTheme={setTheme} />
<ProductList cart={cart} setCart={setCart} user={user} />
<Footer theme={theme} />
)
}
function Header({ user, theme, setTheme }) {
return (
<header>
<UserProfile user={user} />
<ThemeToggle theme={theme} setTheme={setTheme} />
</header>
)
}
// Tu passes les props sur 5 niveaux... 😱
Les solutions qu'on va voir
- Context API → pour partager l'état sans prop drilling
- Zustand → state manager simple et moderne
- Redux Toolkit → pour les grosses apps complexes
- Jotai → approche atomique
- TanStack Query → pour les données serveur
Context API - La solution native React
Concept de base
Le Context API permet de partager des données dans toute l'app sans les passer en props.
// 1. Créer un Context
const UserContext = createContext()
// 2. Fournir les données (Provider)
function App() {
const [user, setUser] = useState(null)
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
<MainContent />
</UserContext.Provider>
)
}
// 3. Consommer les données
function Header() {
const { user } = useContext(UserContext)
return <h1>Salut {user?.name}!</h1>
}
Exemple complet : Theme Provider
import { createContext, useContext, useState } from 'react'
// 1. Créer le Context
const ThemeContext = createContext()
// 2. Hook personnalisé pour utiliser le context
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme doit être utilisé dans ThemeProvider')
}
return context
}
// 3. Provider Component
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light')
}
const value = {
theme,
setTheme,
toggleTheme,
isDark: theme === 'dark'
}
return (
<ThemeContext.Provider value={value}>
<div className={`app theme-${theme}`}>
{children}
</div>
</ThemeContext.Provider>
)
}
// 4. Utilisation dans les composants
function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙' : '☀️'}
Passer en mode {theme === 'light' ? 'sombre' : 'clair'}
</button>
)
}
function Header() {
const { isDark } = useTheme()
return (
<header className={isDark ? 'header-dark' : 'header-light'}>
<h1>Mon App</h1>
<ThemeToggle />
</header>
)
}
// 5. Setup dans App.jsx
function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
</ThemeProvider>
)
}
Context multiple avec composition
// Auth Context
const AuthContext = createContext()
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const login = async (email, password) => {
setIsLoading(true)
try {
// Appel API
const userData = await api.login(email, password)
setUser(userData)
} catch (error) {
throw error
} finally {
setIsLoading(false)
}
}
const logout = () => {
setUser(null)
localStorage.removeItem('token')
}
return (
<AuthContext.Provider value={{
user,
login,
logout,
isLoading,
isAuthenticated: !!user
}}>
{children}
</AuthContext.Provider>
)
}
// Cart Context
const CartContext = createContext()
export function CartProvider({ children }) {
const [items, setItems] = useState([])
const addItem = (product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id)
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
return [...prev, { ...product, quantity: 1 }]
})
}
const removeItem = (productId) => {
setItems(prev => prev.filter(item => item.id !== productId))
}
const updateQuantity = (productId, quantity) => {
if (quantity <= 0) {
removeItem(productId)
return
}
setItems(prev =>
prev.map(item =>
item.id === productId
? { ...item, quantity }
: item
)
)
}
const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
return (
<CartContext.Provider value={{
items,
addItem,
removeItem,
updateQuantity,
total,
itemCount: items.reduce((sum, item) => sum + item.quantity, 0)
}}>
{children}
</CartContext.Provider>
)
}
// Composition des providers
function App() {
return (
<AuthProvider>
<ThemeProvider>
<CartProvider>
<Router>
<Layout />
</Router>
</CartProvider>
</ThemeProvider>
</AuthProvider>
)
}
Hooks personnalisés pour les contexts
// hooks/useAuth.js
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth doit être utilisé dans AuthProvider')
}
return context
}
// hooks/useCart.js
export function useCart() {
const context = useContext(CartContext)
if (!context) {
throw new Error('useCart doit être utilisé dans CartProvider')
}
return context
}
// Utilisation simplifiée
function ProductCard({ product }) {
const { addItem } = useCart()
const { isAuthenticated } = useAuth()
const handleAddToCart = () => {
if (!isAuthenticated) {
alert('Connectez-vous pour ajouter au panier')
return
}
addItem(product)
}
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>{product.price}€</p>
<button onClick={handleAddToCart}>
Ajouter au panier
</button>
</div>
)
}
Zustand - State manager moderne et simple
Pourquoi Zustand ?
Zustand (= "état" en allemand) est un state manager ultra simple et performant :
- 📦 Tiny : 2.5kb gzippé
- 🚀 Rapide : pas de providers nécessaires
- 🔧 Simple : API minimaliste
- 🎯 TypeScript natif
Installation
npm install zustand
Store basique
import { create } from 'zustand'
// Créer un store
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}))
// Utiliser le store (n'importe où dans l'app)
function Counter() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div>
<h2>Compteur: {count}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
)
}
// Dans un autre composant (pas besoin de provider!)
function CounterDisplay() {
const count = useCounterStore((state) => state.count)
return <p>Valeur actuelle: {count}</p>
}
Store complexe : E-commerce
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
// Interface pour TypeScript (optionnel)
interface Product {
id: string
name: string
price: number
image: string
}
interface CartItem extends Product {
quantity: number
}
interface CartStore {
items: CartItem[]
isOpen: boolean
addItem: (product: Product) => void
removeItem: (productId: string) => void
updateQuantity: (productId: string, quantity: number) => void
clearCart: () => void
toggleCart: () => void
total: number
itemCount: number
}
// Store avec middleware
const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
isOpen: false,
addItem: (product) => set((state) => {
const existingItem = state.items.find(item => item.id === product.id)
if (existingItem) {
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
} else {
return {
items: [...state.items, { ...product, quantity: 1 }]
}
}
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter(item => item.id !== productId)
})),
updateQuantity: (productId, quantity) => set((state) => {
if (quantity <= 0) {
return {
items: state.items.filter(item => item.id !== productId)
}
}
return {
items: state.items.map(item =>
item.id === productId
? { ...item, quantity }
: item
)
}
}),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
// Computed values
get total() {
return get().items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
},
get itemCount() {
return get().items.reduce((sum, item) => sum + item.quantity, 0)
}
}),
{
name: 'cart-storage', // Clé localStorage
partialize: (state) => ({ items: state.items }) // Ne persister que les items
}
),
{ name: 'cart-store' } // Nom pour Redux DevTools
)
)
// Utilisation dans les composants
function ProductCard({ product }) {
const addItem = useCartStore((state) => state.addItem)
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.price}€</p>
<button onClick={() => addItem(product)}>
Ajouter au panier
</button>
</div>
)
}
function CartSummary() {
const { itemCount, total, toggleCart } = useCartStore()
return (
<button onClick={toggleCart} className="cart-button">
🛒 {itemCount} articles - {total.toFixed(2)}€
</button>
)
}
function CartModal() {
const { items, isOpen, removeItem, updateQuantity, clearCart, toggleCart } = useCartStore()
if (!isOpen) return null
return (
<div className="cart-modal">
<div className="cart-content">
<div className="cart-header">
<h2>Votre panier</h2>
<button onClick={toggleCart}>❌</button>
</div>
{items.length === 0 ? (
<p>Votre panier est vide</p>
) : (
<>
{items.map(item => (
<div key={item.id} className="cart-item">
<img src={item.image} alt={item.name} />
<div>
<h4>{item.name}</h4>
<p>{item.price}€</p>
</div>
<div className="quantity-controls">
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
-
</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
+
</button>
</div>
<button onClick={() => removeItem(item.id)}>
🗑️
</button>
</div>
))}
<div className="cart-actions">
<button onClick={clearCart} className="btn-secondary">
Vider le panier
</button>
<button className="btn-primary">
Commander ({total.toFixed(2)}€)
</button>
</div>
</>
)}
</div>
</div>
)
}
Store avec actions async
const useProductStore = create((set, get) => ({
products: [],
isLoading: false,
error: null,
// Action synchrone
setProducts: (products) => set({ products }),
// Action asynchrone
fetchProducts: async () => {
set({ isLoading: true, error: null })
try {
const response = await fetch('/api/products')
if (!response.ok) throw new Error('Erreur lors du chargement')
const products = await response.json()
set({ products, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
// Filtrer les produits
searchProducts: (query) => {
const { products } = get()
return products.filter(product =>
product.name.toLowerCase().includes(query.toLowerCase())
)
},
// Trouver un produit
getProductById: (id) => {
const { products } = get()
return products.find(product => product.id === id)
}
}))
// Hook personnalisé pour les produits filtrés
function useProductSearch(query = '') {
return useProductStore((state) =>
query ? state.searchProducts(query) : state.products
)
}
// Utilisation
function ProductList() {
const [searchQuery, setSearchQuery] = useState('')
const { isLoading, error, fetchProducts } = useProductStore()
const products = useProductSearch(searchQuery)
useEffect(() => {
fetchProducts()
}, [])
if (isLoading) return <div>Chargement...</div>
if (error) return <div>Erreur: {error}</div>
return (
<div>
<input
type="text"
placeholder="Rechercher un produit..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="products-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}
Slices (découper le store)
// Pour de gros stores, on peut découper en slices
// authSlice.js
const createAuthSlice = (set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
try {
const response = await api.login(email, password)
set({
user: response.user,
token: response.token,
isAuthenticated: true
})
} catch (error) {
throw error
}
},
logout: () => set({
user: null,
token: null,
isAuthenticated: false
})
})
// cartSlice.js
const createCartSlice = (set, get) => ({
items: [],
total: 0,
addToCart: (product) => {
// Logic...
}
})
// Store principal
const useAppStore = create((set, get) => ({
...createAuthSlice(set, get),
...createCartSlice(set, get)
}))
Redux Toolkit - Pour les grosses applications
Quand utiliser Redux ?
Redux est utile pour :
- Grosses équipes (10+ développeurs)
- Apps très complexes (100+ composants)
- Logique métier complexe (machines à état)
- Debug avancé (time travel, replay)
Installation
npm install @reduxjs/toolkit react-redux
Store basique avec RTK
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment: (state) => {
state.value += 1 // Immer permet la mutation directe
},
decrement: (state) => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
reset: (state) => {
state.value = 0
}
}
})
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
export default counterSlice.reducer
// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer
}
})
// main.jsx
import { Provider } from 'react-redux'
import { store } from './store'
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
// Utilisation dans un composant
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, reset } from './store/counterSlice'
function Counter() {
const count = useSelector((state) => state.counter.value)
const dispatch = useDispatch()
return (
<div>
<h2>Compteur: {count}</h2>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(reset())}>Reset</button>
</div>
)
}
RTK Query pour les appels API
// api/productsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api/',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
}
}),
tagTypes: ['Product'],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => 'products',
providesTags: ['Product']
}),
getProductById: builder.query({
query: (id) => `products/${id}`,
providesTags: (result, error, id) => [{ type: 'Product', id }]
}),
createProduct: builder.mutation({
query: (newProduct) => ({
url: 'products',
method: 'POST',
body: newProduct
}),
invalidatesTags: ['Product']
}),
updateProduct: builder.mutation({
query: ({ id, ...patch }) => ({
url: `products/${id}`,
method: 'PATCH',
body: patch
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Product', id }]
}),
deleteProduct: builder.mutation({
query: (id) => ({
url: `products/${id}`,
method: 'DELETE'
}),
invalidatesTags: ['Product']
})
})
})
export const {
useGetProductsQuery,
useGetProductByIdQuery,
useCreateProductMutation,
useUpdateProductMutation,
useDeleteProductMutation
} = productsApi
// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import { productsApi } from '../api/productsApi'
export const store = configureStore({
reducer: {
[productsApi.reducerPath]: productsApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(productsApi.middleware)
})
// Composant
function ProductList() {
const { data: products, error, isLoading } = useGetProductsQuery()
const [deleteProduct] = useDeleteProductMutation()
if (isLoading) return <div>Chargement...</div>
if (error) return <div>Erreur: {error.message}</div>
return (
<div className="products-grid">
{products?.map(product => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>{product.price}€</p>
<button onClick={() => deleteProduct(product.id)}>
Supprimer
</button>
</div>
))}
</div>
)
}
Jotai - Approche atomique
Concept des atomes
Jotai utilise des atomes : des petites unités d'état indépendantes.
npm install jotai
import { atom, useAtom } from 'jotai'
// Créer des atomes
const countAtom = atom(0)
const nameAtom = atom('Andy')
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<div>
<p>Compteur: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
function Greeting() {
const [name, setName] = useAtom(nameAtom)
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
/>
<p>Salut {name}!</p>
</div>
)
}
Atomes dérivés
import { atom, useAtom, useAtomValue } from 'jotai'
// Atomes de base
const firstNameAtom = atom('Andy')
const lastNameAtom = atom('Cin')
// Atome dérivé (calculé)
const fullNameAtom = atom((get) => {
const firstName = get(firstNameAtom)
const lastName = get(lastNameAtom)
return `${firstName} ${lastName}`
})
// Atome dérivé avec setter
const upperCaseNameAtom = atom(
(get) => get(fullNameAtom).toUpperCase(),
(get, set, newValue) => {
const [first, last] = newValue.split(' ')
set(firstNameAtom, first)
set(lastNameAtom, last)
}
)
function NameForm() {
const [firstName, setFirstName] = useAtom(firstNameAtom)
const [lastName, setLastName] = useAtom(lastNameAtom)
const fullName = useAtomValue(fullNameAtom)
const [upperName, setUpperName] = useAtom(upperCaseNameAtom)
return (
<div>
<input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Prénom"
/>
<input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Nom"
/>
<p>Nom complet: {fullName}</p>
<p>En majuscules: {upperName}</p>
<button onClick={() => setUpperName('JOHN DOE')}>
Changer pour John Doe
</button>
</div>
)
}
Atomes avec storage
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// Persister dans localStorage
const themeAtom = atomWithStorage('theme', 'light')
// Atome pour les préférences utilisateur
const userPrefsAtom = atomWithStorage('userPrefs', {
theme: 'light',
language: 'fr',
notifications: true
})
function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom)
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Mode {theme === 'light' ? 'sombre' : 'clair'}
</button>
)
}
TanStack Query
Concept clé
TanStack Query (ex React Query) est parfait pour gérer :
- Cache des données API
- Synchronisation serveur/client
- Loading states automatiques
- Optimistic updates
npm install @tanstack/react-query
Setup de base
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<Layout />
</Router>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Queries (lecture de données)
import { useQuery, useQueryClient } from '@tanstack/react-query'
// Hook personnalisé pour les produits
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const response = await fetch('/api/products')
if (!response.ok) throw new Error('Erreur lors du chargement')
return response.json()
}
})
}
// Hook pour un produit spécifique
function useProduct(productId) {
return useQuery({
queryKey: ['products', productId],
queryFn: async () => {
const response = await fetch(`/api/products/${productId}`)
if (!response.ok) throw new Error('Produit non trouvé')
return response.json()
},
enabled: !!productId // Ne pas exécuter si pas d'ID
})
}
// Utilisation dans les composants
function ProductList() {
const { data: products, isLoading, error } = useProducts()
if (isLoading) return <div>Chargement des produits...</div>
if (error) return <div>Erreur: {error.message}</div>
return (
<div className="products-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
function ProductDetail({ productId }) {
const { data: product, isLoading, error } = useProduct(productId)
if (isLoading) return <div>Chargement du produit...</div>
if (error) return <div>Erreur: {error.message}</div>
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>{product.price}€</p>
</div>
)
}
Mutations (modification de données)
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useCreateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newProduct) => {
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProduct)
})
if (!response.ok) throw new Error('Erreur lors de la création')
return response.json()
},
onSuccess: () => {
// Invalider le cache des produits
queryClient.invalidateQueries({ queryKey: ['products'] })
alert('Produit créé avec succès!')
},
onError: (error) => {
alert(`Erreur: ${error.message}`)
}
})
}
function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...updates }) => {
const response = await fetch(`/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
})
if (!response.ok) throw new Error('Erreur lors de la mise à jour')
return response.json()
},
onSuccess: (updatedProduct) => {
// Mettre à jour le cache directement
queryClient.setQueryData(['products', updatedProduct.id], updatedProduct)
queryClient.invalidateQueries({ queryKey: ['products'] })
}
})
}
// Utilisation
function CreateProductForm() {
const createProduct = useCreateProduct()
const [formData, setFormData] = useState({ name: '', price: 0 })
const handleSubmit = (e) => {
e.preventDefault()
createProduct.mutate(formData)
}
return (
<form onSubmit={handleSubmit}>
<input
placeholder="Nom du produit"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<input
type="number"
placeholder="Prix"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
/>
<button
type="submit"
disabled={createProduct.isPending}
>
{createProduct.isPending ? 'Création...' : 'Créer le produit'}
</button>
</form>
)
}
Optimistic Updates
function useToggleFavorite() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ productId, isFavorite }) => {
const response = await fetch(`/api/products/${productId}/favorite`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isFavorite })
})
if (!response.ok) throw new Error('Erreur')
return response.json()
},
// Optimistic update
onMutate: async ({ productId, isFavorite }) => {
// Annuler les queries en cours
await queryClient.cancelQueries({ queryKey: ['products', productId] })
// Snapshot de la valeur précédente
const previousProduct = queryClient.getQueryData(['products', productId])
// Mettre à jour optimistiquement
queryClient.setQueryData(['products', productId], old => ({
...old,
isFavorite
}))
// Retourner le contexte pour rollback si erreur
return { previousProduct, productId }
},
// Rollback en cas d'erreur
onError: (err, variables, context) => {
queryClient.setQueryData(
['products', context.productId],
context.previousProduct
)
},
// Toujours refetch après
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: ['products', variables.productId] })
}
})
}
function FavoriteButton({ product }) {
const toggleFavorite = useToggleFavorite()
const handleToggle = () => {
toggleFavorite.mutate({
productId: product.id,
isFavorite: !product.isFavorite
})
}
return (
<button onClick={handleToggle}>
{product.isFavorite ? '❤️' : '🤍'} Favori
</button>
)
}
Ressources pour aller plus loin
Documentation officielle
- 📚 Zustand
- 🚀 TanStack Query
- 🔧 Redux Toolkit
- ⚛️ Jotai