Paperless-ngx : zéro papier, tout indexé et cherchable sur ton serveur
Pendant des années, j'ai conservé mes relevés bancaires, mes factures d'électricité et mes documents administratifs…
La gestion d’état est cruciale dans les applications React modernes. En 2025, trois solutions dominent : Redux Toolkit (le standard de l’industrie), Zustand (léger et moderne), et Context API (natif React). Découvrons leurs forces et faiblesses.
// src/contexts/CartContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartContextType {
items: CartItem[];
addItem: (item: Omit) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
total: number;
itemCount: number;
}
const CartContext = createContext(undefined);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState([]);
const addItem = (item: Omit) => {
setItems(current => {
const existing = current.find(i => i.id === item.id);
if (existing) {
return current.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
);
}
return [...current, { ...item, quantity: 1 }];
});
};
const removeItem = (id: number) => {
setItems(current => current.filter(i => i.id !== id));
};
const updateQuantity = (id: number, quantity: number) => {
if (quantity <= 0) {
removeItem(id);
return;
}
setItems(current =>
current.map(i => (i.id === id ? { ...i, quantity } : i))
);
};
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const value = {
items,
addItem,
removeItem,
updateQuantity,
total,
itemCount
};
return {children} ;
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}
// src/App.tsx
import { CartProvider } from './contexts/CartContext';
function App() {
return (
);
}
// src/components/Header.tsx
import { useCart } from '@/contexts/CartContext';
function Header() {
const { itemCount } = useCart();
return (
# Mon Shop
Panier: {itemCount} articles
);
}
// src/components/ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
const { addItem } = useCart();
return (
### {product.name}
{product.price}€
);
}
// ❌ Problème : tous les consommateurs re-render
const CartContext = createContext({ items: [], addItem: () => {} });
// ✅ Solution 1 : Séparer les contexts
const CartItemsContext = createContext([]);
const CartActionsContext = createContext(undefined);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState([]);
const actions = useMemo(() => ({
addItem: (item: CartItem) => setItems(prev => [...prev, item]),
removeItem: (id: number) => setItems(prev => prev.filter(i => i.id !== id))
}), []);
return (
{children}
);
}
// ✅ Solution 2 : useContextSelector (avec use-context-selector)
import { createContext } from 'use-context-selector';
const CartContext = createContext(undefined);
function Header() {
// Ne re-render que si itemCount change
const itemCount = useContextSelector(CartContext, v => v.itemCount);
return Panier: {itemCount};
}
Avantages :
Inconvénients :
npm install zustand
// src/stores/cartStore.ts
import { create } from 'zustand';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
clearCart: () => void;
total: () => number;
itemCount: () => number;
}
export const useCartStore = create((set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
)
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
updateQuantity: (id, quantity) => set((state) => ({
items: state.items.map(i =>
i.id === id ? { ...i, quantity } : i
)
})),
clearCart: () => set({ items: [] }),
total: () => {
const { items } = get();
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
itemCount: () => {
const { items } = get();
return items.reduce((sum, item) => sum + item.quantity, 0);
}
}));
// src/components/Header.tsx
import { useCartStore } from '@/stores/cartStore';
function Header() {
// Sélecteur : ne re-render que si itemCount change
const itemCount = useCartStore(state => state.itemCount());
return (
# Mon Shop
Panier: {itemCount} articles
);
}
// src/components/ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
// Sélectionner uniquement la fonction
const addItem = useCartStore(state => state.addItem);
return (
### {product.name}
);
}
// src/components/Cart.tsx
function Cart() {
const { items, removeItem, total } = useCartStore(state => ({
items: state.items,
removeItem: state.removeItem,
total: state.total()
}));
return (
{items.map(item => (
{item.name}
{item.quantity}
))}
Total: {total}€
);
}
// src/stores/cartStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export const useCartStore = create()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => ({ /* ... */ })),
// ... autres actions
}),
{
name: 'cart-storage', // clé localStorage
storage: createJSONStorage(() => localStorage),
}
)
);
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
export const useCartStore = create()(
immer((set) => ({
items: [],
// ✅ Mutations directes grâce à Immer
addItem: (item) => set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
}),
updateQuantity: (id, quantity) => set((state) => {
const item = state.items.find(i => i.id === id);
if (item) {
item.quantity = quantity;
}
})
}))
);
import { devtools } from 'zustand/middleware';
export const useCartStore = create()(
devtools(
persist(
immer((set) => ({
// ... store
})),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
// src/stores/slices/userSlice.ts
export interface UserSlice {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
export const createUserSlice = (set: any): UserSlice => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null })
});
// src/stores/slices/cartSlice.ts
export const createCartSlice = (set: any): CartSlice => ({
items: [],
addItem: (item) => set((state: any) => ({ /* ... */ }))
});
// src/stores/index.ts
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';
type StoreState = UserSlice & CartSlice;
export const useStore = create()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a)
}));
Avantages :
Inconvénients :
npm install @reduxjs/toolkit react-redux
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './slices/cartSlice';
import userReducer from './slices/userSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
user: userReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false
})
});
export type RootState = ReturnType;
export type AppDispatch = typeof store.dispatch;
// src/store/slices/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
loading: boolean;
error: string | null;
}
const initialState: CartState = {
items: [],
loading: false,
error: null
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem: (state, action: PayloadAction>) => {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action: PayloadAction) => {
state.items = state.items.filter(i => i.id !== action.payload);
},
updateQuantity: (state, action: PayloadAction<{ id: number; quantity: number }>) => {
const item = state.items.find(i => i.id === action.payload.id);
if (item) {
item.quantity = action.payload.quantity;
}
},
clearCart: (state) => {
state.items = [];
}
}
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
// Selectors
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectItemCount = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
// src/store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch();
export const useAppSelector: TypedUseSelectorHook = useSelector;
// src/App.tsx
import { Provider } from 'react-redux';
import { store } from './store';
function App() {
return (
);
}
// src/components/Header.tsx
import { useAppSelector } from '@/store/hooks';
import { selectItemCount } from '@/store/slices/cartSlice';
function Header() {
const itemCount = useAppSelector(selectItemCount);
return (
# Mon Shop
Panier: {itemCount} articles
);
}
// src/components/ProductCard.tsx
import { useAppDispatch } from '@/store/hooks';
import { addItem } from '@/store/slices/cartSlice';
function ProductCard({ product }: { product: Product }) {
const dispatch = useAppDispatch();
return (
### {product.name}
);
}
// src/store/slices/productsSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface Product {
id: number;
name: string;
price: number;
}
interface ProductsState {
items: Product[];
loading: boolean;
error: string | null;
}
const initialState: ProductsState = {
items: [],
loading: false,
error: null
};
// Async thunk
export const fetchProducts = createAsyncThunk(
'products/fetchProducts',
async (category?: string) => {
const url = category
? /api/products?category=${category}
: '/api/products';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
return response.json();
}
);
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'An error occurred';
});
}
});
export default productsSlice.reducer;
// src/store/api/productsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Product {
id: number;
name: string;
price: number;
}
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Products'],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => 'products',
providesTags: ['Products']
}),
getProduct: builder.query({
query: (id) => products/${id},
providesTags: (result, error, id) => [{ type: 'Products', id }]
}),
createProduct: builder.mutation>({
query: (body) => ({
url: 'products',
method: 'POST',
body
}),
invalidatesTags: ['Products']
}),
updateProduct: builder.mutation({
query: ({ id, ...body }) => ({
url: products/${id},
method: 'PUT',
body
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Products', id }]
}),
deleteProduct: builder.mutation({
query: (id) => ({
url: products/${id},
method: 'DELETE'
}),
invalidatesTags: ['Products']
})
})
});
export const {
useGetProductsQuery,
useGetProductQuery,
useCreateProductMutation,
useUpdateProductMutation,
useDeleteProductMutation
} = productsApi;
// Ajouter à configureStore
export const store = configureStore({
reducer: {
cart: cartReducer,
[productsApi.reducerPath]: productsApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(productsApi.middleware)
});
// src/components/ProductList.tsx
import {
useGetProductsQuery,
useDeleteProductMutation
} from '@/store/api/productsApi';
function ProductList() {
const { data: products, isLoading, error } = useGetProductsQuery();
const [deleteProduct, { isLoading: isDeleting }] = useDeleteProductMutation();
if (isLoading) return Chargement...;
if (error) return Erreur;
return (
{products?.map(product => (
### {product.name}
))}
);
}
// Préchargement optimiste
function ProductCard({ productId }: { productId: number }) {
const { data: product } = useGetProductQuery(productId);
return {product?.name};
}
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER
} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import cartReducer from './slices/cartSlice';
const persistConfig = {
key: 'root',
storage,
whitelist: ['cart'] // Seulement persister le cart
};
const persistedCartReducer = persistReducer(persistConfig, cartReducer);
export const store = configureStore({
reducer: {
cart: persistedCartReducer,
user: userReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
}
})
});
export const persistor = persistStore(store);
// src/App.tsx
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from './store';
function App() {
return (
);
}
Avantages :
Inconvénients :
| Solution | Taille min+gzip | Impact |
|---|---|---|
| Context API | 0kb | Natif React |
| Zustand | 1.1kb | Minimal |
| Redux Toolkit | 12kb | Moyen |
| MobX | 16kb | Élevé |
| Recoil | 14kb | Moyen |
// Benchmark code
function benchmark() {
console.time('Context API');
for (let i = 0; i < 1000; i++) {
// Update context
}
console.timeEnd('Context API'); // ~450ms
console.time('Zustand');
for (let i = 0; i < 1000; i++) {
// Update zustand
}
console.timeEnd('Zustand'); // ~85ms
console.time('Redux Toolkit');
for (let i = 0; i < 1000; i++) {
// Dispatch action
}
console.timeEnd('Redux Toolkit'); // ~120ms
}
| Solution | Temps (ms) | Re-renders |
|---|---|---|
| Context API | 450 | Tous les consommateurs |
| Zustand | 85 | Seulement si sélecteur change |
| Redux Toolkit | 120 | Avec selectors optimisés |
// ✅ Context API : Pour
// - Thème
// - Internationalisation (i18n)
// - Authentification (simple)
// - État UI partagé simple
// ✅ Zustand : Pour
// - Applications petites à moyennes
// - État global simple
// - Performance critique
// - Besoin de simplicité
// ✅ Redux Toolkit : Pour
// - Applications grandes/complexes
// - État global complexe
// - Data fetching centralisé (RTK Query)
// - Besoin de DevTools avancés
// - Middleware custom
// - Time-travel debugging
// Avant (Context API)
const CartContext = createContext(undefined);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState([]);
// ...
return {children} ;
}
// Après (Zustand)
export const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ /* ... */ }))
}));
// Migration du code
// Avant
const { items, addItem } = useCart();
// Après
const { items, addItem } = useCartStore();
// Ou avec sélecteur
const items = useCartStore(state => state.items);
const addItem = useCartStore(state => state.addItem);
// Avant (Zustand)
export const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ /* ... */ }))
}));
// Après (Redux Toolkit)
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => {
// ...
}
}
});
// Migration du code
// Avant
const addItem = useCartStore(state => state.addItem);
// Après
const dispatch = useAppDispatch();
dispatch(addItem(product));
// CartContext.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { CartProvider, useCart } from './CartContext';
function TestComponent() {
const { items, addItem } = useCart();
return (
{items.length}
);
}
describe('CartContext', () => {
it('adds items to cart', () => {
render(
);
expect(screen.getByTestId('count')).toHaveTextContent('0');
fireEvent.click(screen.getByText('Add'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
});
// cartStore.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCartStore } from './cartStore';
describe('cartStore', () => {
beforeEach(() => {
useCartStore.setState({ items: [] });
});
it('adds item to cart', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({ id: 1, name: 'Test', price: 10 });
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].name).toBe('Test');
});
});
// cartSlice.test.ts
import cartReducer, { addItem, removeItem } from './cartSlice';
describe('cartSlice', () => {
const initialState = { items: [], loading: false, error: null };
it('adds item', () => {
const item = { id: 1, name: 'Test', price: 10 };
const state = cartReducer(initialState, addItem(item));
expect(state.items).toHaveLength(1);
expect(state.items[0].quantity).toBe(1);
});
it('removes item', () => {
const state = {
items: [{ id: 1, name: 'Test', price: 10, quantity: 1 }],
loading: false,
error: null
};
const newState = cartReducer(state, removeItem(1));
expect(newState.items).toHaveLength(0);
});
});
// Petit projet (< 10 composants)
// → Context API
// Projet moyen (10-50 composants)
// → Zustand
// Grand projet (> 50 composants)
// → Redux Toolkit
// Projet avec beaucoup de data fetching
// → Redux Toolkit + RTK Query
// ou TanStack Query + Zustand
// Context API (auth) + Zustand (cart)
const App = () => (
);
// Redux Toolkit + TanStack Query
// Redux pour l'état global
// TanStack Query pour le data fetching
// Zustand + TanStack Query (moderne et léger)
export const useCartStore = create(...);
export const useProducts = () => useQuery(['products'], fetchProducts);
Chaque solution a ses forces :
Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.