Contexte, Redux Toolkit et Zustand pour l’état dans React
Comparez Context, Redux Toolkit et Zustand pour un portail vidéo, puis combinez les stores clients avec TanStack Query pour gérer l’état serveur.
1. Introduction
Choisir la manière dont l’état de React est géré fait partie des décisions architecturales qui se révèlent problématiques lorsqu’elles sont erronées. L’utilisation de useState local suffit pour des interfaces utilisateur isolées, mais les applications ont rapidement besoin d’une authentification partagée, d’un thème commun, d’un état du lecteur et de caches serveur. Les outils permettant de gérer cela ont évolué en même temps que React lui-même.
Dans les premières versions de React, on comptait sur l’état local et le transfert des propriétés. Flux (2014) puis Redux (2015) ont fait du stock unique prévisible avec des réducteurs la solution par défaut pour les grandes équipes. MobX (2015) a proposé des observables comme alternative plus légère. React 16.3 (2018) a introduit une API Context moderne permettant de gérer des valeurs globales sans bibliothèque externe. React 16.8 (2019) a ajouté useReducer pour des transitions similaires à celles de Redux au sein de React. Zustand (2019) a réduit le code générique grâce à un stock basé sur des hooks. Jotai (2020) et Recoil (2020) ont mis en avant des modèles d’état atomique et dérivé. TanStack Query (anciennement React Query, 2020) a séparé les aspects liés à l’état serveur — mise en cache, pagination, mutations — de l’état de l’interface utilisateur.
useStatelocal — des indicateurs et valeurs d’interface qui ne quittent jamais un composant.
useReducer + Contexte — Transitions complexes qui doivent néanmoins rester dans React.Un prototype de portail vidéo applique les mêmes domaines — thème, authentification, cours, lecteur — sous Contexte, Redux Toolkit et Zustand, puis les combine avec TanStack Query.
2. Concepts avec explication détaillée
API Contexte — État global intégré
Context transmet des valeurs à travers l’arborescence sans utiliser de props threadés. Il fait partie intégrante de React, ne nécessite aucun package et convient aux mises à jour fréquentes telles que le thème, l’authentification, la langue et les flags de fonctionnalité.
Cette architecture se compose de trois éléments : createContext() crée le contexte (avec une valeur par défaut optionnelle), Context.Provider diffuse une value, et useContext(Context) la lit dans les éléments descendants. Lorsque la valeur du fournisseur change, tous les consommateurs se rérendent à nouveau.
Cette diffusion a un coût. Les consommateurs se rérendent même s’ils n’ont besoin que d’un seul champ. Les solutions incluent de séparer les contextes en fonction du taux de mise à jour, de mémoriser les valeurs des fournisseurs avec useMemo, ou d’utiliser des outils de sélection pour les consommateurs tels que use-context-selector.
Un portail vidéo peut conserver le thème, les informations d’authentification et l’état du lecteur dans des contextes distincts :
// contexts/ThemeContext.tsx
'use client';
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
type ActualTheme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
actualTheme: ActualTheme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme') as Theme;
if (saved && ['light', 'dark', 'system'].includes(saved)) return saved;
}
return 'system';
});
const [actualTheme, setActualTheme] = useState<ActualTheme>('light');
// Detect system preference
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const updateActualTheme = () => {
if (theme === 'system') {
setActualTheme(mediaQuery.matches ? 'dark' : 'light');
} else {
setActualTheme(theme as ActualTheme);
}
};
updateActualTheme();
mediaQuery.addEventListener('change', updateActualTheme);
return () => mediaQuery.removeEventListener('change', updateActualTheme);
}, [theme]);
// Persist to localStorage and apply to document
useEffect(() => {
localStorage.setItem('theme', theme);
if (actualTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme, actualTheme]);
const toggleTheme = useCallback(() => {
setTheme(prev => {
if (prev === 'light') return 'dark';
if (prev === 'dark') return 'system';
return 'light';
});
}, []);
const value = useMemo(() => ({
theme,
actualTheme,
setTheme,
toggleTheme,
}), [theme, actualTheme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// contexts/AuthContext.tsx
'use client';
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useRouter } from 'next/navigation';
interface User {
id: string;
name: string;
email: string;
avatar?: string;
role: 'user' | 'instructor' | 'admin';
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: () => void;
logout: () => void;
register: (name: string, email: string, password: string) => Promise<void>;
hasPermission: (permission: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Check for existing session on mount
useEffect(() => {
const checkSession = async () => {
try {
const response = await fetch('/api/auth/session');
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Session check failed:', error);
} finally {
setIsLoading(false);
}
};
checkSession();
}, []);
const login = useCallback(async (email: string, password: string) => {
setIsLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
const userData = await response.json();
setUser(userData);
router.push('/dashboard');
} finally {
setIsLoading(false);
}
}, [router]);
const loginWithGoogle = useCallback(() => {
window.location.href = '/api/auth/google';
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
router.push('/');
}, [router]);
const register = useCallback(async (name: string, email: string, password: string) => {
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
if (!response.ok) throw new Error('Registration failed');
const userData = await response.json();
setUser(userData);
router.push('/dashboard');
} finally {
setIsLoading(false);
}
}, [router]);
const hasPermission = useCallback((permission: string): boolean => {
if (!user) return false;
if (user.role === 'admin') return true;
// Check specific permissions based on role
const permissions: Record<string, string[]> = {
user: ['view_courses', 'enroll_courses', 'leave_reviews'],
instructor: ['view_courses', 'create_courses', 'edit_courses', 'view_analytics'],
admin: ['*'],
};
return permissions[user.role]?.includes(permission) || permissions[user.role]?.includes('*') || false;
}, [user]);
const value = useMemo(() => ({
user,
isLoading,
isAuthenticated: !!user,
login,
loginWithGoogle,
logout,
register,
hasPermission,
}), [user, isLoading, login, loginWithGoogle, logout, register, hasPermission]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// contexts/VideoPlayerContext.tsx - Complex state with Context + useReducer
'use client';
import { createContext, useContext, useReducer, useCallback, ReactNode } from 'react';
// State types
interface VideoState {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
playbackRate: number;
quality: 'auto' | '1080p' | '720p' | '480p';
subtitlesEnabled: boolean;
subtitleTrack: string | null;
bookmarks: Bookmark[];
showNotes: boolean;
notes: string;
}
interface Bookmark {
id: string;
timestamp: number;
note: string;
}
// Action types
type VideoAction =
| { type: 'PLAY' }
| { type: 'PAUSE' }
| { type: 'TOGGLE_PLAY' }
| { type: 'SET_TIME'; payload: number }
| { type: 'SET_DURATION'; payload: number }
| { type: 'SET_VOLUME'; payload: number }
| { type: 'TOGGLE_MUTE' }
| { type: 'SET_PLAYBACK_RATE'; payload: number }
| { type: 'SET_QUALITY'; payload: VideoState['quality'] }
| { type: 'TOGGLE_SUBTITLES' }
| { type: 'ADD_BOOKMARK'; payload: { timestamp: number; note: string } }
| { type: 'REMOVE_BOOKMARK'; payload: string }
| { type: 'TOGGLE_NOTES' }
| { type: 'SET_NOTES'; payload: string }
| { type: 'RESET' };
// Reducer
const initialState: VideoState = {
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 80,
isMuted: false,
playbackRate: 1,
quality: 'auto',
subtitlesEnabled: false,
subtitleTrack: null,
bookmarks: [],
showNotes: false,
notes: '',
};
function videoReducer(state: VideoState, action: VideoAction): VideoState {
switch (action.type) {
case 'PLAY':
return { ...state, isPlaying: true };
case 'PAUSE':
return { ...state, isPlaying: false };
case 'TOGGLE_PLAY':
return { ...state, isPlaying: !state.isPlaying };
case 'SET_TIME':
return { ...state, currentTime: Math.max(0, Math.min(action.payload, state.duration)) };
case 'SET_DURATION':
return { ...state, duration: action.payload };
case 'SET_VOLUME': {
const newVolume = Math.max(0, Math.min(100, action.payload));
return { ...state, volume: newVolume, isMuted: newVolume === 0 };
}
case 'TOGGLE_MUTE':
return { ...state, isMuted: !state.isMuted };
case 'SET_PLAYBACK_RATE':
return { ...state, playbackRate: action.payload };
case 'SET_QUALITY':
return { ...state, quality: action.payload };
case 'TOGGLE_SUBTITLES':
return { ...state, subtitlesEnabled: !state.subtitlesEnabled };
case 'ADD_BOOKMARK': {
const newBookmark: Bookmark = {
id: Date.now().toString(),
timestamp: action.payload.timestamp,
note: action.payload.note,
};
return { ...state, bookmarks: [...state.bookmarks, newBookmark] };
}
case 'REMOVE_BOOKMARK':
return { ...state, bookmarks: state.bookmarks.filter(b => b.id !== action.payload) };
case 'TOGGLE_NOTES':
return { ...state, showNotes: !state.showNotes };
case 'SET_NOTES':
return { ...state, notes: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
}
// Context type
interface VideoPlayerContextType {
state: VideoState;
play: () => void;
pause: () => void;
togglePlay: () => void;
setTime: (time: number) => void;
setDuration: (duration: number) => void;
setVolume: (volume: number) => void;
toggleMute: () => void;
setPlaybackRate: (rate: number) => void;
setQuality: (quality: VideoState['quality']) => void;
toggleSubtitles: () => void;
addBookmark: (timestamp: number, note: string) => void;
removeBookmark: (id: string) => void;
toggleNotes: () => void;
setNotes: (notes: string) => void;
reset: () => void;
}
const VideoPlayerContext = createContext<VideoPlayerContextType | undefined>(undefined);
export function VideoPlayerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(videoReducer, initialState);
const play = useCallback(() => dispatch({ type: 'PLAY' }), []);
const pause = useCallback(() => dispatch({ type: 'PAUSE' }), []);
const togglePlay = useCallback(() => dispatch({ type: 'TOGGLE_PLAY' }), []);
const setTime = useCallback((time: number) => dispatch({ type: 'SET_TIME', payload: time }), []);
const setDuration = useCallback((duration: number) => dispatch({ type: 'SET_DURATION', payload: duration }), []);
const setVolume = useCallback((volume: number) => dispatch({ type: 'SET_VOLUME', payload: volume }), []);
const toggleMute = useCallback(() => dispatch({ type: 'TOGGLE_MUTE' }), []);
const setPlaybackRate = useCallback((rate: number) => dispatch({ type: 'SET_PLAYBACK_RATE', payload: rate }), []);
const setQuality = useCallback((quality: VideoState['quality']) => dispatch({ type: 'SET_QUALITY', payload: quality }), []);
const toggleSubtitles = useCallback(() => dispatch({ type: 'TOGGLE_SUBTITLES' }), []);
const addBookmark = useCallback((timestamp: number, note: string) =>
dispatch({ type: 'ADD_BOOKMARK', payload: { timestamp, note } }), []);
const removeBookmark = useCallback((id: string) => dispatch({ type: 'REMOVE_BOOKMARK', payload: id }), []);
const toggleNotes = useCallback(() => dispatch({ type: 'TOGGLE_NOTES' }), []);
const setNotes = useCallback((notes: string) => dispatch({ type: 'SET_NOTES', payload: notes }), []);
const reset = useCallback(() => dispatch({ type: 'RESET' }), []);
const value = useMemo(() => ({
state,
play,
pause,
togglePlay,
setTime,
setDuration,
setVolume,
toggleMute,
setPlaybackRate,
setQuality,
toggleSubtitles,
addBookmark,
removeBookmark,
toggleNotes,
setNotes,
reset,
}), [state, play, pause, togglePlay, setTime, setDuration, setVolume, toggleMute,
setPlaybackRate, setQuality, toggleSubtitles, addBookmark, removeBookmark,
toggleNotes, setNotes, reset]);
return (
<VideoPlayerContext.Provider value={value}>
{children}
</VideoPlayerContext.Provider>
);
}
export function useVideoPlayer() {
const context = useContext(VideoPlayerContext);
if (context === undefined) {
throw new Error('useVideoPlayer must be used within a VideoPlayerProvider');
}
return context;
}
Les fournisseurs sont placés à la racine du layout :
// app/layout.tsx - App with multiple Context providers
import { ThemeProvider } from '@/contexts/ThemeContext';
import { AuthProvider } from '@/contexts/AuthContext';
import { VideoPlayerProvider } from '@/contexts/VideoPlayerContext';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider>
<AuthProvider>
<VideoPlayerProvider>
{children}
</VideoPlayerProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}
Les consommateurs restent légers :
// components/ThemeToggle.tsx - Using Context
'use client';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
export function ThemeToggle() {
const { theme, setTheme, actualTheme } = useTheme();
return (
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`p-2 rounded ${theme === 'light' ? 'bg-white dark:bg-gray-700 shadow-sm' : ''}`}
aria-label="Light mode"
>
<Sun className="w-4 h-4" />
</button>
<button
onClick={() => setTheme('dark')}
className={`p-2 rounded ${theme === 'dark' ? 'bg-white dark:bg-gray-700 shadow-sm' : ''}`}
aria-label="Dark mode"
>
<Moon className="w-4 h-4" />
</button>
<button
onClick={() => setTheme('system')}
className={`p-2 rounded ${theme === 'system' ? 'bg-white dark:bg-gray-700 shadow-sm' : ''}`}
aria-label="System preference"
>
<Monitor className="w-4 h-4" />
</button>
</div>
);
}
Le contexte est facile à adopter et suffisant lorsque les mises à jour sont rares. Les mécanismes de mise à jour fréquente ou les arbres complexes nécessitent généralement un stockage utilisant des sélecteurs plus restreints.
Redux Toolkit pour de grands stocks coordonnés
RTK est l’outil pris en charge pour les applications Redux actuelles. Il élimine les procédures classiques et propose des outils pour configurer le stockage, définir des slices, écrire des thunks asynchrones et effectuer des requêtes via RTK Query.
Éléments de base :
- Slices — Regrouper les reducers et les actions dans un même fichier à l’aide de
createSlice. createAsyncThunk— Flux standard en attente/terminé/refusé pour les tâches asynchrones.
RTK convient aux grands projets avec des interactions d’état complexes, de nombreux contributeurs, un débogage par « voyage dans le temps », des besoins importants en matière de moyen de transmission, ainsi qu’à une préférence pour des mises à jour strictement prévisibles.
Une boutique de portail vidéo pourrait gérer des cours, un lecteur, des préférences et une partie d’API :
// store/index.ts - Redux store configuration
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import coursesReducer from './slices/coursesSlice';
import videoPlayerReducer from './slices/videoPlayerSlice';
import userPreferencesReducer from './slices/userPreferencesSlice';
import { apiSlice } from './services/apiSlice';
export const store = configureStore({
reducer: {
courses: coursesReducer,
videoPlayer: videoPlayerReducer,
userPreferences: userPreferencesReducer,
[apiSlice.reducerPath]: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(apiSlice.middleware),
devTools: process.env.NODE_ENV !== 'production',
});
// Enable refetchOnFocus/refetchOnReconnect behaviors
setupListeners(store.dispatch);
// Infer types for TypeScript
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// store/hooks.ts - Typed hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// store/slices/coursesSlice.ts - Courses slice with createSlice and createAsyncThunk
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
price: number;
rating: number;
enrolledCount: number;
instructor: {
id: string;
name: string;
avatar: string;
};
isEnrolled: boolean;
}
interface CoursesState {
items: Course[];
featured: Course[];
recommended: Course[];
searchResults: Course[];
currentCourse: Course | null;
isLoading: boolean;
error: string | null;
filters: {
category: string | null;
level: string | null;
sortBy: 'newest' | 'popular' | 'rating' | 'price_low' | 'price_high';
searchTerm: string;
};
pagination: {
page: number;
totalPages: number;
totalItems: number;
};
}
const initialState: CoursesState = {
items: [],
featured: [],
recommended: [],
searchResults: [],
currentCourse: null,
isLoading: false,
error: null,
filters: {
category: null,
level: null,
sortBy: 'popular',
searchTerm: '',
},
pagination: {
page: 1,
totalPages: 1,
totalItems: 0,
},
};
// Async thunks
export const fetchCourses = createAsyncThunk(
'courses/fetchCourses',
async (params: { page?: number; category?: string; sortBy?: string } = {}) => {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', params.page.toString());
if (params.category) queryParams.append('category', params.category);
if (params.sortBy) queryParams.append('sort', params.sortBy);
const response = await fetch(`/api/courses?${queryParams}`);
if (!response.ok) throw new Error('Failed to fetch courses');
return response.json();
}
);
export const fetchCourseById = createAsyncThunk(
'courses/fetchCourseById',
async (courseId: string) => {
const response = await fetch(`/api/courses/${courseId}`);
if (!response.ok) throw new Error('Failed to fetch course');
return response.json();
}
);
export const fetchFeaturedCourses = createAsyncThunk(
'courses/fetchFeaturedCourses',
async () => {
const response = await fetch('/api/courses/featured');
return response.json();
}
);
export const fetchRecommendedCourses = createAsyncThunk(
'courses/fetchRecommendedCourses',
async () => {
const response = await fetch('/api/courses/recommended');
return response.json();
}
);
export const searchCourses = createAsyncThunk(
'courses/searchCourses',
async (searchTerm: string) => {
const response = await fetch(`/api/courses/search?q=${encodeURIComponent(searchTerm)}`);
return response.json();
}
);
export const enrollInCourse = createAsyncThunk(
'courses/enrollInCourse',
async (courseId: string, { rejectWithValue }) => {
try {
const response = await fetch(`/api/courses/${courseId}/enroll`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
return rejectWithValue(error.message);
}
return { courseId, enrolled: true };
} catch (error) {
return rejectWithValue('Enrollment failed');
}
}
);
const coursesSlice = createSlice({
name: 'courses',
initialState,
reducers: {
setFilters: (state, action: PayloadAction<Partial<CoursesState['filters']>>) => {
state.filters = { ...state.filters, ...action.payload };
state.pagination.page = 1; // Reset pagination when filters change
},
resetFilters: (state) => {
state.filters = initialState.filters;
state.pagination.page = 1;
},
setPage: (state, action: PayloadAction<number>) => {
state.pagination.page = action.payload;
},
clearSearchResults: (state) => {
state.searchResults = [];
state.filters.searchTerm = '';
},
updateCourseEnrollment: (state, action: PayloadAction<{ courseId: string; isEnrolled: boolean }>) => {
const { courseId, isEnrolled } = action.payload;
// Update in items list
const itemIndex = state.items.findIndex(c => c.id === courseId);
if (itemIndex !== -1) {
state.items[itemIndex].isEnrolled = isEnrolled;
if (isEnrolled) {
state.items[itemIndex].enrolledCount += 1;
} else {
state.items[itemIndex].enrolledCount -= 1;
}
}
// Update in featured list
const featuredIndex = state.featured.findIndex(c => c.id === courseId);
if (featuredIndex !== -1) {
state.featured[featuredIndex].isEnrolled = isEnrolled;
}
// Update current course
if (state.currentCourse?.id === courseId) {
state.currentCourse.isEnrolled = isEnrolled;
}
},
},
extraReducers: (builder) => {
builder
// fetchCourses
.addCase(fetchCourses.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchCourses.fulfilled, (state, action) => {
state.isLoading = false;
state.items = action.payload.courses;
state.pagination = {
page: action.payload.page,
totalPages: action.payload.totalPages,
totalItems: action.payload.total,
};
})
.addCase(fetchCourses.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch courses';
})
// fetchCourseById
.addCase(fetchCourseById.pending, (state) => {
state.isLoading = true;
state.currentCourse = null;
})
.addCase(fetchCourseById.fulfilled, (state, action) => {
state.isLoading = false;
state.currentCourse = action.payload;
})
.addCase(fetchCourseById.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch course';
})
// fetchFeaturedCourses
.addCase(fetchFeaturedCourses.fulfilled, (state, action) => {
state.featured = action.payload;
})
// fetchRecommendedCourses
.addCase(fetchRecommendedCourses.fulfilled, (state, action) => {
state.recommended = action.payload;
})
// searchCourses
.addCase(searchCourses.fulfilled, (state, action) => {
state.searchResults = action.payload;
})
// enrollInCourse
.addCase(enrollInCourse.fulfilled, (state, action) => {
const { courseId, enrolled } = action.payload;
coursesSlice.caseReducers.updateCourseEnrollment(state, {
type: 'courses/updateCourseEnrollment',
payload: { courseId, isEnrolled: enrolled },
});
});
},
});
export const { setFilters, resetFilters, setPage, clearSearchResults, updateCourseEnrollment } = coursesSlice.actions;
export default coursesSlice.reducer;
// store/slices/videoPlayerSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Bookmark {
id: string;
timestamp: number;
note: string;
}
interface VideoPlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
playbackRate: number;
quality: 'auto' | '1080p' | '720p' | '480p';
subtitlesEnabled: boolean;
subtitlesTrack: string | null;
bookmarks: Bookmark[];
showNotes: boolean;
notes: string;
currentVideoId: string | null;
currentCourseId: string | null;
}
const initialState: VideoPlayerState = {
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 80,
isMuted: false,
playbackRate: 1,
quality: 'auto',
subtitlesEnabled: false,
subtitlesTrack: null,
bookmarks: [],
showNotes: false,
notes: '',
currentVideoId: null,
currentCourseId: null,
};
const videoPlayerSlice = createSlice({
name: 'videoPlayer',
initialState,
reducers: {
setCurrentVideo: (state, action: PayloadAction<{ videoId: string; courseId: string }>) => {
state.currentVideoId = action.payload.videoId;
state.currentCourseId = action.payload.courseId;
state.currentTime = 0;
state.isPlaying = false;
state.bookmarks = [];
},
play: (state) => {
state.isPlaying = true;
},
pause: (state) => {
state.isPlaying = false;
},
togglePlay: (state) => {
state.isPlaying = !state.isPlaying;
},
setTime: (state, action: PayloadAction<number>) => {
state.currentTime = Math.max(0, Math.min(action.payload, state.duration));
},
setDuration: (state, action: PayloadAction<number>) => {
state.duration = action.payload;
},
setVolume: (state, action: PayloadAction<number>) => {
const volume = Math.max(0, Math.min(100, action.payload));
state.volume = volume;
if (volume === 0) state.isMuted = true;
else if (state.isMuted) state.isMuted = false;
},
toggleMute: (state) => {
state.isMuted = !state.isMuted;
},
setPlaybackRate: (state, action: PayloadAction<number>) => {
state.playbackRate = action.payload;
},
setQuality: (state, action: PayloadAction<VideoPlayerState['quality']>) => {
state.quality = action.payload;
},
toggleSubtitles: (state) => {
state.subtitlesEnabled = !state.subtitlesEnabled;
},
addBookmark: (state, action: PayloadAction<{ timestamp: number; note: string }>) => {
const newBookmark: Bookmark = {
id: Date.now().toString(),
timestamp: action.payload.timestamp,
note: action.payload.note,
};
state.bookmarks.push(newBookmark);
},
removeBookmark: (state, action: PayloadAction<string>) => {
state.bookmarks = state.bookmarks.filter(b => b.id !== action.payload);
},
toggleNotes: (state) => {
state.showNotes = !state.showNotes;
},
setNotes: (state, action: PayloadAction<string>) => {
state.notes = action.payload;
},
seekForward: (state) => {
state.currentTime = Math.min(state.duration, state.currentTime + 10);
},
seekBackward: (state) => {
state.currentTime = Math.max(0, state.currentTime - 10);
},
resetPlayer: (state) => {
return { ...initialState, currentCourseId: state.currentCourseId };
},
},
});
export const {
setCurrentVideo,
play,
pause,
togglePlay,
setTime,
setDuration,
setVolume,
toggleMute,
setPlaybackRate,
setQuality,
toggleSubtitles,
addBookmark,
removeBookmark,
toggleNotes,
setNotes,
seekForward,
seekBackward,
resetPlayer,
} = videoPlayerSlice.actions;
export default videoPlayerSlice.reducer;
// store/slices/userPreferencesSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UserPreferencesState {
theme: 'light' | 'dark' | 'system';
language: string;
autoplay: boolean;
quality: 'auto' | '1080p' | '720p' | '480p';
subtitlesEnabled: boolean;
subtitlesLanguage: string;
volume: number;
playbackRate: number;
notifications: {
email: boolean;
push: boolean;
courseUpdates: boolean;
newContent: boolean;
};
recentSearches: string[];
bookmarksView: 'grid' | 'list';
sortPreferences: {
courses: 'newest' | 'popular' | 'rating';
videos: 'recent' | 'popular';
};
}
const loadFromLocalStorage = (): Partial<UserPreferencesState> => {
if (typeof window === 'undefined') return {};
try {
const saved = localStorage.getItem('userPreferences');
if (saved) return JSON.parse(saved);
} catch (error) {
console.error('Failed to load preferences:', error);
}
return {};
};
const initialState: UserPreferencesState = {
theme: 'system',
language: 'en',
autoplay: true,
quality: 'auto',
subtitlesEnabled: false,
subtitlesLanguage: 'en',
volume: 80,
playbackRate: 1,
notifications: {
email: true,
push: false,
courseUpdates: true,
newContent: true,
},
recentSearches: [],
bookmarksView: 'grid',
sortPreferences: {
courses: 'popular',
videos: 'recent',
},
...loadFromLocalStorage(),
};
const userPreferencesSlice = createSlice({
name: 'userPreferences',
initialState,
reducers: {
setTheme: (state, action: PayloadAction<UserPreferencesState['theme']>) => {
state.theme = action.payload;
this.persist(state);
},
setLanguage: (state, action: PayloadAction<string>) => {
state.language = action.payload;
this.persist(state);
},
setAutoplay: (state, action: PayloadAction<boolean>) => {
state.autoplay = action.payload;
this.persist(state);
},
setQuality: (state, action: PayloadAction<UserPreferencesState['quality']>) => {
state.quality = action.payload;
this.persist(state);
},
setSubtitlesEnabled: (state, action: PayloadAction<boolean>) => {
state.subtitlesEnabled = action.payload;
this.persist(state);
},
setSubtitlesLanguage: (state, action: PayloadAction<string>) => {
state.subtitlesLanguage = action.payload;
this.persist(state);
},
setVolume: (state, action: PayloadAction<number>) => {
state.volume = action.payload;
this.persist(state);
},
setPlaybackRate: (state, action: PayloadAction<number>) => {
state.playbackRate = action.payload;
this.persist(state);
},
updateNotifications: (state, action: PayloadAction<Partial<UserPreferencesState['notifications']>>) => {
state.notifications = { ...state.notifications, ...action.payload };
this.persist(state);
},
addRecentSearch: (state, action: PayloadAction<string>) => {
const term = action.payload.trim();
if (!term) return;
state.recentSearches = [
term,
...state.recentSearches.filter(s => s !== term),
].slice(0, 10);
this.persist(state);
},
clearRecentSearches: (state) => {
state.recentSearches = [];
this.persist(state);
},
setBookmarksView: (state, action: PayloadAction<'grid' | 'list'>) => {
state.bookmarksView = action.payload;
this.persist(state);
},
setSortPreference: (state, action: PayloadAction<{ type: 'courses' | 'videos'; value: string }>) => {
state.sortPreferences[action.payload.type] = action.payload.value as any;
this.persist(state);
},
resetPreferences: () => initialState,
},
});
// Helper to persist to localStorage
function persist(state: UserPreferencesState) {
if (typeof window === 'undefined') return;
const { ...persistable } = state;
localStorage.setItem('userPreferences', JSON.stringify(persistable));
}
export const {
setTheme,
setLanguage,
setAutoplay,
setQuality,
setSubtitlesEnabled,
setSubtitlesLanguage,
setVolume,
setPlaybackRate,
updateNotifications,
addRecentSearch,
clearRecentSearches,
setBookmarksView,
setSortPreference,
resetPreferences,
} = userPreferencesSlice.actions;
export default userPreferencesSlice.reducer;
// store/services/apiSlice.ts - RTK Query for data fetching
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
price: number;
rating: number;
enrolledCount: number;
instructor: {
id: string;
name: string;
avatar: string;
};
isEnrolled?: boolean;
}
export interface Video {
id: string;
title: string;
duration: number;
url: string;
thumbnail: string;
order: number;
isFree: boolean;
}
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers) => {
const token = localStorage.getItem('access_token');
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ['Course', 'Courses', 'Video', 'Progress', 'Bookmark'],
endpoints: (builder) => ({
// Query endpoints
getCourses: builder.query<{ courses: Course[]; total: number; page: number }, { page?: number; category?: string; sort?: string }>({
query: (params) => ({
url: '/courses',
params,
}),
providesTags: (result) =>
result
? [
...result.courses.map(({ id }) => ({ type: 'Course' as const, id })),
{ type: 'Courses', id: 'LIST' },
]
: [{ type: 'Courses', id: 'LIST' }],
}),
getCourse: builder.query<Course, string>({
query: (id) => `/courses/${id}`,
providesTags: (result, error, id) => [{ type: 'Course', id }],
}),
getCourseVideos: builder.query<Video[], string>({
query: (courseId) => `/courses/${courseId}/videos`,
providesTags: (result, error, courseId) => [
{ type: 'Course', id: courseId },
{ type: 'Video', id: 'LIST' },
],
}),
getVideoProgress: builder.query<{ position: number; percentage: number; completed: boolean }, string>({
query: (videoId) => `/progress/${videoId}`,
providesTags: (result, error, videoId) => [{ type: 'Progress', id: videoId }],
}),
getBookmarks: builder.query<Video[], void>({
query: () => '/bookmarks',
providesTags: ['Bookmark'],
}),
// Mutation endpoints
enrollInCourse: builder.mutation<{ success: boolean }, string>({
query: (courseId) => ({
url: `/courses/${courseId}/enroll`,
method: 'POST',
}),
invalidatesTags: (result, error, courseId) => [
{ type: 'Course', id: courseId },
{ type: 'Courses', id: 'LIST' },
],
}),
updateProgress: builder.mutation<void, { videoId: string; position: number; percentage: number }>({
query: ({ videoId, position, percentage }) => ({
url: `/progress/${videoId}`,
method: 'POST',
body: { position, percentage },
}),
invalidatesTags: (result, error, { videoId }) => [
{ type: 'Progress', id: videoId },
],
}),
toggleBookmark: builder.mutation<{ isBookmarked: boolean }, string>({
query: (videoId) => ({
url: `/bookmarks/${videoId}`,
method: 'POST',
}),
invalidatesTags: ['Bookmark'],
async onQueryStarted(videoId, { dispatch, queryFulfilled }) {
// Optimistic update example
const patchResult = dispatch(
apiSlice.util.updateQueryData('getBookmarks', undefined, (draft) => {
// Optimistically add/remove bookmark
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
submitReview: builder.mutation<void, { courseId: string; rating: number; comment: string }>({
query: ({ courseId, rating, comment }) => ({
url: `/courses/${courseId}/reviews`,
method: 'POST',
body: { rating, comment },
}),
invalidatesTags: (result, error, { courseId }) => [{ type: 'Course', id: courseId }],
}),
}),
});
// Auto-generated hooks
export const {
useGetCoursesQuery,
useGetCourseQuery,
useGetCourseVideosQuery,
useGetVideoProgressQuery,
useGetBookmarksQuery,
useEnrollInCourseMutation,
useUpdateProgressMutation,
useToggleBookmarkMutation,
useSubmitReviewMutation,
} = apiSlice;
Les composants envoient ensuite des commandes et effectuent des sélections à l’aide d’hooks typés :
// components/CourseListRedux.tsx - React component using Redux
'use client';
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { fetchCourses, setFilters, setPage } from '@/store/slices/coursesSlice';
import { useGetCoursesQuery } from '@/store/services/apiSlice';
import { RootState } from '@/store';
export function CourseListRedux() {
// Using traditional Redux slices
const dispatch = useAppDispatch();
const { items: courses, isLoading, filters, pagination } = useAppSelector(
(state: RootState) => state.courses
);
// Using RTK Query for automatic caching
const { data: apiCourses, isLoading: apiLoading } = useGetCoursesQuery({
page: pagination.page,
category: filters.category || undefined,
sort: filters.sortBy,
});
useEffect(() => {
dispatch(fetchCourses({
page: pagination.page,
category: filters.category || undefined,
sortBy: filters.sortBy,
}));
}, [dispatch, pagination.page, filters.category, filters.sortBy]);
const handleFilterChange = (category: string | null) => {
dispatch(setFilters({ category }));
};
const handleSortChange = (sortBy: typeof filters.sortBy) => {
dispatch(setFilters({ sortBy }));
};
if (isLoading) {
return <div className="flex justify-center p-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>;
}
return (
<div className="container mx-auto px-4 py-8">
{/* Filters */}
<div className="mb-6 flex gap-4">
<select
value={filters.category || ''}
onChange={(e) => handleFilterChange(e.target.value || null)}
className="px-3 py-2 border rounded-lg"
>
<option value="">All Categories</option>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
<option value="nextjs">Next.js</option>
</select>
<select
value={filters.sortBy}
onChange={(e) => handleSortChange(e.target.value as any)}
className="px-3 py-2 border rounded-lg"
>
<option value="newest">Newest</option>
<option value="popular">Most Popular</option>
<option value="rating">Highest Rated</option>
<option value="price_low">Price: Low to High</option>
<option value="price_high">Price: High to Low</option>
</select>
</div>
{/* Course Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<CourseCardRedux key={course.id} course={course} />
))}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="mt-8 flex justify-center gap-2">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => dispatch(setPage(p))}
className={`px-3 py-1 rounded ${
p === pagination.page
? 'bg-blue-600 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{p}
</button>
))}
</div>
)}
</div>
);
}
function CourseCardRedux({ course }: { course: Course }) {
const [enroll] = useEnrollInCourseMutation();
return (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<img src={course.thumbnail} alt={course.title} className="w-full h-48 object-cover" />
<div className="p-4">
<h3 className="font-semibold text-lg">{course.title}</h3>
<p className="text-sm text-gray-600 mt-1">{course.instructor.name}</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-yellow-500">★</span>
<span>{course.rating}</span>
<span className="text-gray-400">•</span>
<span>{course.enrolledCount.toLocaleString()} students</span>
</div>
<div className="mt-3 flex justify-between items-center">
<span className="text-xl font-bold">${course.price}</span>
{!course.isEnrolled ? (
<button
onClick={() => enroll(course.id)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Enroll
</button>
) : (
<span className="px-4 py-2 bg-green-100 text-green-700 rounded">Enrolled ✓</span>
)}
</div>
</div>
</div>
);
}
La structure de RTK s’adapte bien et est facile à documenter ; l’inconvénient est qu’elle exige plus de formalités que Context ou Zustand pour les petits écrans.
Zustand — Gestion d’état avec un minimum de code générique
Zustand s’adresse aux équipes qui souhaitent une sémantique de stockage sans les formalités de Redux — surtout lorsque la performance et une API concise sont plus importantes qu’un vocabulaire d’actions formel.
Un portail peut séparer les besoins liés aux cours et aux joueurs au sein de stores Zustand grâce à persist, DevTools et des middleware associés :
// store/zustand/courseStore.ts
import { create } from 'zustand';
import { persist, devtools, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
price: number;
rating: number;
enrolledCount: number;
instructor: {
id: string;
name: string;
avatar: string;
};
isEnrolled: boolean;
}
interface FilterState {
category: string | null;
level: string | null;
sortBy: 'newest' | 'popular' | 'rating' | 'price_low' | 'price_high';
searchTerm: string;
}
interface CourseStore {
// State
courses: Course[];
featured: Course[];
recommended: Course[];
currentCourse: Course | null;
isLoading: boolean;
error: string | null;
filters: FilterState;
page: number;
totalPages: number;
hasMore: boolean;
// Actions
fetchCourses: () => Promise<void>;
fetchCourseById: (id: string) => Promise<void>;
fetchFeatured: () => Promise<void>;
fetchRecommended: () => Promise<void>;
searchCourses: (term: string) => Promise<void>;
enrollInCourse: (courseId: string) => Promise<boolean>;
setFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void;
resetFilters: () => void;
setPage: (page: number) => void;
clearError: () => void;
// Selectors (computed)
getFilteredAndSortedCourses: () => Course[];
}
const initialFilters: FilterState = {
category: null,
level: null,
sortBy: 'popular',
searchTerm: '',
};
// Create store with multiple middleware
export const useCourseStore = create<CourseStore>()(
devtools(
persist(
subscribeWithSelector(
immer((set, get) => ({
// Initial state
courses: [],
featured: [],
recommended: [],
currentCourse: null,
isLoading: false,
error: null,
filters: initialFilters,
page: 1,
totalPages: 1,
hasMore: true,
// Actions
fetchCourses: async () => {
set({ isLoading: true, error: null }, false, 'courses/fetch/start');
try {
const { page, filters } = get();
const params = new URLSearchParams();
params.append('page', page.toString());
if (filters.category) params.append('category', filters.category);
if (filters.sortBy) params.append('sort', filters.sortBy);
if (filters.searchTerm) params.append('search', filters.searchTerm);
const response = await fetch(`/api/courses?${params}`);
if (!response.ok) throw new Error('Failed to fetch courses');
const data = await response.json();
set((state) => {
state.courses = page === 1 ? data.courses : [...state.courses, ...data.courses];
state.totalPages = data.totalPages;
state.hasMore = data.hasMore;
state.isLoading = false;
}, false, 'courses/fetch/success');
} catch (error) {
set({ error: (error as Error).message, isLoading: false }, false, 'courses/fetch/error');
}
},
fetchCourseById: async (id: string) => {
set({ isLoading: true, error: null }, false, 'course/fetch/start');
try {
const response = await fetch(`/api/courses/${id}`);
if (!response.ok) throw new Error('Course not found');
const course = await response.json();
set({ currentCourse: course, isLoading: false }, false, 'course/fetch/success');
} catch (error) {
set({ error: (error as Error).message, isLoading: false }, false, 'course/fetch/error');
}
},
fetchFeatured: async () => {
try {
const response = await fetch('/api/courses/featured');
const featured = await response.json();
set({ featured }, false, 'courses/featured/success');
} catch (error) {
console.error('Failed to fetch featured:', error);
}
},
fetchRecommended: async () => {
try {
const response = await fetch('/api/courses/recommended');
const recommended = await response.json();
set({ recommended }, false, 'courses/recommended/success');
} catch (error) {
console.error('Failed to fetch recommended:', error);
}
},
searchCourses: async (term: string) => {
if (!term.trim()) {
set(
(state) => {
state.filters.searchTerm = '';
state.page = 1;
},
false,
'courses/search/clear'
);
await get().fetchCourses();
return;
}
set(
(state) => {
state.filters.searchTerm = term;
state.page = 1;
state.isLoading = true;
},
false,
'courses/search/start'
);
try {
const response = await fetch(`/api/courses/search?q=${encodeURIComponent(term)}`);
const data = await response.json();
set(
(state) => {
state.courses = data.courses;
state.totalPages = data.totalPages;
state.hasMore = data.hasMore;
state.isLoading = false;
},
false,
'courses/search/success'
);
} catch (error) {
set({ error: (error as Error).message, isLoading: false }, false, 'courses/search/error');
}
},
enrollInCourse: async (courseId: string) => {
try {
const response = await fetch(`/api/courses/${courseId}/enroll`, {
method: 'POST',
});
if (!response.ok) return false;
// Update local state
set((state) => {
// Update in courses list
const courseIndex = state.courses.findIndex(c => c.id === courseId);
if (courseIndex !== -1) {
state.courses[courseIndex].isEnrolled = true;
state.courses[courseIndex].enrolledCount += 1;
}
// Update current course
if (state.currentCourse?.id === courseId) {
state.currentCourse.isEnrolled = true;
}
}, false, 'courses/enroll/success');
return true;
} catch (error) {
console.error('Enrollment failed:', error);
return false;
}
},
setFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
set(
(state) => {
state.filters[key] = value;
state.page = 1; // Reset pagination on filter change
},
false,
`courses/filter/${String(key)}`
);
get().fetchCourses();
},
resetFilters: () => {
set(
(state) => {
state.filters = initialFilters;
state.page = 1;
},
false,
'courses/filters/reset'
);
get().fetchCourses();
},
setPage: (page: number) => {
set({ page }, false, 'courses/page/set');
get().fetchCourses();
},
clearError: () => set({ error: null }, false, 'courses/error/clear'),
// Selector: get filtered and sorted courses (computed)
getFilteredAndSortedCourses: () => {
const { courses, filters } = get();
let result = [...courses];
// Apply filters
if (filters.category) {
// In real app, filter by category
}
if (filters.level) {
// Filter by level
}
// Apply sorting
switch (filters.sortBy) {
case 'newest':
result.sort((a, b) => 0); // Add sorting logic
break;
case 'popular':
result.sort((a, b) => b.enrolledCount - a.enrolledCount);
break;
case 'rating':
result.sort((a, b) => b.rating - a.rating);
break;
case 'price_low':
result.sort((a, b) => a.price - b.price);
break;
case 'price_high':
result.sort((a, b) => b.price - a.price);
break;
}
return result;
},
}))
),
{
name: 'course-store', // localStorage key
partialize: (state) => ({
// Only persist these fields
filters: state.filters,
page: state.page,
}),
}
),
{ name: 'CourseStore' } // Redux DevTools name
)
);
// Selector hooks for performance
export const useCourses = () => useCourseStore((state) => state.courses);
export const useFeatured = () => useCourseStore((state) => state.featured);
export const useRecommended = () => useCourseStore((state) => state.recommended);
export const useCurrentCourse = () => useCourseStore((state) => state.currentCourse);
export const useCourseLoading = () => useCourseStore((state) => state.isLoading);
export const useCourseError = () => useCourseStore((state) => state.error);
export const useFilters = () => useCourseStore((state) => state.filters);
export const usePagination = () => useCourseStore((state) => ({ page: state.page, totalPages: state.totalPages }));
// store/zustand/videoPlayerStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface Bookmark {
id: string;
timestamp: number;
note: string;
}
interface VideoPlayerState {
// State
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
playbackRate: number;
quality: 'auto' | '1080p' | '720p' | '480p';
subtitlesEnabled: boolean;
subtitlesTrack: string | null;
bookmarks: Bookmark[];
showNotes: boolean;
notes: string;
currentVideoId: string | null;
currentCourseId: string | null;
// Actions
play: () => void;
pause: () => void;
togglePlay: () => void;
setTime: (time: number) => void;
setDuration: (duration: number) => void;
setVolume: (volume: number) => void;
toggleMute: () => void;
setPlaybackRate: (rate: number) => void;
setQuality: (quality: VideoPlayerState['quality']) => void;
toggleSubtitles: () => void;
addBookmark: (timestamp: number, note?: string) => void;
removeBookmark: (id: string) => void;
toggleNotes: () => void;
setNotes: (notes: string) => void;
seekForward: (seconds?: number) => void;
seekBackward: (seconds?: number) => void;
reset: () => void;
loadVideo: (videoId: string, courseId: string) => void;
// Computed
getProgressPercentage: () => number;
getFormattedTime: () => string;
}
export const useVideoPlayerStore = create<VideoPlayerState>()(
devtools(
persist(
(set, get) => ({
// Initial state
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 80,
isMuted: false,
playbackRate: 1,
quality: 'auto',
subtitlesEnabled: false,
subtitlesTrack: null,
bookmarks: [],
showNotes: false,
notes: '',
currentVideoId: null,
currentCourseId: null,
// Actions
play: () => set({ isPlaying: true }, false, 'video/play'),
pause: () => set({ isPlaying: false }, false, 'video/pause'),
togglePlay: () => set((state) => ({ isPlaying: !state.isPlaying }), false, 'video/togglePlay'),
setTime: (time: number) => set((state) => ({
currentTime: Math.max(0, Math.min(time, state.duration))
}), false, 'video/setTime'),
setDuration: (duration: number) => set({ duration }, false, 'video/setDuration'),
setVolume: (volume: number) => {
const clampedVolume = Math.max(0, Math.min(100, volume));
set((state) => ({
volume: clampedVolume,
isMuted: clampedVolume === 0 ? true : state.isMuted,
}), false, 'video/setVolume');
},
toggleMute: () => set((state) => ({ isMuted: !state.isMuted }), false, 'video/toggleMute'),
setPlaybackRate: (rate: number) => set({ playbackRate: rate }, false, 'video/setPlaybackRate'),
setQuality: (quality: VideoPlayerState['quality']) => set({ quality }, false, 'video/setQuality'),
toggleSubtitles: () => set((state) => ({
subtitlesEnabled: !state.subtitlesEnabled
}), false, 'video/toggleSubtitles'),
addBookmark: (timestamp: number, note: string = '') => {
const newBookmark: Bookmark = {
id: Date.now().toString(),
timestamp,
note,
};
set((state) => ({
bookmarks: [...state.bookmarks, newBookmark].sort((a, b) => a.timestamp - b.timestamp)
}), false, 'video/addBookmark');
},
removeBookmark: (id: string) => set((state) => ({
bookmarks: state.bookmarks.filter(b => b.id !== id)
}), false, 'video/removeBookmark'),
toggleNotes: () => set((state) => ({ showNotes: !state.showNotes }), false, 'video/toggleNotes'),
setNotes: (notes: string) => set({ notes }, false, 'video/setNotes'),
seekForward: (seconds: number = 10) => set((state) => ({
currentTime: Math.min(state.duration, state.currentTime + seconds)
}), false, 'video/seekForward'),
seekBackward: (seconds: number = 10) => set((state) => ({
currentTime: Math.max(0, state.currentTime - seconds)
}), false, 'video/seekBackward'),
reset: () => set({
isPlaying: false,
currentTime: 0,
duration: 0,
bookmarks: [],
showNotes: false,
notes: '',
}, false, 'video/reset'),
loadVideo: (videoId: string, courseId: string) => {
// Reset and load new video
set({
currentVideoId: videoId,
currentCourseId: courseId,
currentTime: 0,
duration: 0,
isPlaying: false,
bookmarks: [],
}, false, 'video/load');
// Fetch saved progress from API (async)
fetch(`/api/progress/${videoId}`)
.then(res => res.json())
.then(data => {
if (data.position) {
set({ currentTime: data.position }, false, 'video/restoreProgress');
}
})
.catch(console.error);
},
// Computed values
getProgressPercentage: () => {
const { currentTime, duration } = get();
return duration > 0 ? (currentTime / duration) * 100 : 0;
},
getFormattedTime: () => {
const { currentTime, duration } = get();
const format = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return `${format(currentTime)} / ${format(duration)}`;
},
}),
{
name: 'video-player-store',
partialize: (state) => ({
volume: state.volume,
playbackRate: state.playbackRate,
quality: state.quality,
subtitlesEnabled: state.subtitlesEnabled,
}),
}
),
{ name: 'VideoPlayerStore' }
)
);
// Individual selector hooks for better performance
export const useIsPlaying = () => useVideoPlayerStore((state) => state.isPlaying);
export const useCurrentTime = () => useVideoPlayerStore((state) => state.currentTime);
export const useDuration = () => useVideoPlayerStore((state) => state.duration);
export const useVolume = () => useVideoPlayerStore((state) => state.volume);
export const usePlaybackRate = () => useVideoPlayerStore((state) => state.playbackRate);
export const useBookmarks = () => useVideoPlayerStore((state) => state.bookmarks);
export const useVideoPlayerActions = () => useVideoPlayerStore((state) => ({
play: state.play,
pause: state.pause,
togglePlay: state.togglePlay,
setTime: state.setTime,
setVolume: state.setVolume,
toggleMute: state.toggleMute,
setPlaybackRate: state.setPlaybackRate,
addBookmark: state.addBookmark,
removeBookmark: state.removeBookmark,
seekForward: state.seekForward,
seekBackward: state.seekBackward,
loadVideo: state.loadVideo,
}));
Les composants lisent des parties restreintes de l’état du store :
// components/ZustandVideoPlayer.tsx - Component using Zustand store
'use client';
import { useEffect, useRef } from 'react';
import {
useVideoPlayerStore,
useIsPlaying,
useCurrentTime,
useDuration,
useVolume,
usePlaybackRate,
useBookmarks,
useVideoPlayerActions
} from '@/store/zustand/videoPlayerStore';
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Bookmark } from 'lucide-react';
export function ZustandVideoPlayer({ videoUrl, videoId, courseId }: {
videoUrl: string;
videoId: string;
courseId: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
// Individual selectors for precise re-rendering
const isPlaying = useIsPlaying();
const currentTime = useCurrentTime();
const duration = useDuration();
const volume = useVolume();
const playbackRate = usePlaybackRate();
const bookmarks = useBookmarks();
const progressPercentage = useVideoPlayerStore((state) => state.getProgressPercentage());
// Actions
const {
play,
pause,
togglePlay,
setTime,
setDuration,
setVolume,
toggleMute,
setPlaybackRate,
addBookmark,
seekForward,
seekBackward,
} = useVideoPlayerActions();
// Sync video element with store
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.play();
} else {
video.pause();
}
}, [isPlaying]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume / 100;
}, [volume]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = playbackRate;
}, [playbackRate]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (Math.abs(video.currentTime - currentTime) > 0.5) {
video.currentTime = currentTime;
}
}, [currentTime]);
const handleTimeUpdate = () => {
if (videoRef.current) {
const time = videoRef.current.currentTime;
if (Math.abs(time - currentTime) > 0.1) {
setTime(time);
}
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
};
const handleAddBookmark = () => {
const note = prompt('Add a note for this bookmark:');
addBookmark(currentTime, note || '');
};
const jumpToBookmark = (timestamp: number) => {
setTime(timestamp);
if (videoRef.current) {
videoRef.current.currentTime = timestamp;
}
};
return (
<div className="bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="w-full aspect-video"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={play}
onPause={pause}
/>
<div className="p-4 bg-gray-900 text-white">
{/* Progress Bar */}
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="relative">
<input
type="range"
min="0"
max={duration}
value={currentTime}
onChange={(e) => setTime(parseFloat(e.target.value))}
className="w-full"
/>
{/* Bookmark markers on progress bar */}
<div className="absolute top-0 left-0 right-0 pointer-events-none">
{bookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="absolute w-0.5 h-3 bg-yellow-500 -top-1"
style={{ left: `${(bookmark.timestamp / duration) * 100}%` }}
title={bookmark.note}
/>
))}
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={togglePlay}
className="p-2 bg-blue-600 rounded-full hover:bg-blue-700"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={() => seekBackward(10)}
className="p-2 bg-gray-700 rounded hover:bg-gray-600"
>
<SkipBack className="w-5 h-5" />
</button>
<button
onClick={() => seekForward(10)}
className="p-2 bg-gray-700 rounded hover:bg-gray-600"
>
<SkipForward className="w-5 h-5" />
</button>
<button
onClick={handleAddBookmark}
className="p-2 bg-gray-700 rounded hover:bg-gray-600"
>
<Bookmark className="w-5 h-5" />
</button>
<div className="flex items-center gap-2 ml-4">
<button onClick={toggleMute} className="p-1">
{volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
<input
type="range"
min="0"
max="100"
value={volume}
onChange={(e) => setVolume(parseInt(e.target.value))}
className="w-24"
/>
</div>
<select
value={playbackRate}
onChange={(e) => setPlaybackRate(parseFloat(e.target.value))}
className="px-2 py-1 bg-gray-800 rounded text-sm"
>
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
</div>
{/* Bookmarks List */}
{bookmarks.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-700">
<h4 className="text-sm font-medium mb-2">Bookmarks</h4>
<div className="flex flex-wrap gap-2">
{bookmarks.map((bookmark) => (
<button
key={bookmark.id}
onClick={() => jumpToBookmark(bookmark.timestamp)}
className="px-2 py-1 bg-gray-800 rounded text-xs hover:bg-gray-700"
>
{formatTime(bookmark.timestamp)} - {bookmark.note || 'Untitled'}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
Zustand reste léger tout en prenant en charge la persistance et les rendus sélectifs. C’est un choix idéal lorsque les formalités de Redux l’emportent sur ses avantages.
Comparaison et cadre de décision
Choisissez en fonction de la fréquence des mises à jour, de la taille de l’équipe, et du fait que les données soient gérées côté serveur ou côté client. Préférez Context pour des variables globales peu nombreuses ; RTK lorsque les outils et middleware d’entreprise dominent ; Zustand lorsqu’une API de store très simple suffit ; TanStack Query chaque fois que la source de vérité se trouve sur le réseau.
Matrice de décision
Adaptez l’outil aux contraintes : pente d’apprentissage, code générique, DevTools, profondeur du middleware, ainsi qu’au nombre d’utilisateurs qui modifient le même état. Préférez l’option la plus simple qui permette de maintenir des mises à jour prévisibles en fonction de la taille de votre équipe.
Comparaison des performances
Le contexte se rérend souvent en fonction des changements de valeurs. Redux et Zustand permettent une liaison plus ciblée. Mesurez le comportement du lecteur et les filtres de liste dans des conditions proches du environnement de production avant de choisir une solution.
Intégration avec TanStack Query
Une séparation fiable :
- TanStack Query — État serveur (cache, mise à jour en arrière-plan, mutations).
- Zustand ou Redux — État de l’interface utilisateur côté client (préférences, interface temporaire du lecteur).
- Context — Variables globales qui changent rarement.
// store/combined.ts - Integration example
import { create } from 'zustand';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Zustand for client state
interface UIStore {
sidebarOpen: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
// TanStack Query for server state
export function useCourses() {
return useQuery({
queryKey: ['courses'],
queryFn: () => fetch('/api/courses').then(res => res.json()),
});
}
export function useEnrollCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (courseId: string) =>
fetch(`/api/courses/${courseId}/enroll`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
queryClient.invalidateQueries({ queryKey: ['enrollments'] });
},
});
}
// Component using both
function CoursePage() {
const { theme } = useUIStore();
const { data: courses, isLoading } = useCourses();
const enrollMutation = useEnrollCourse();
return (
<div data-theme={theme}>
{courses?.map(course => (
<CourseCard
key={course.id}
course={course}
onEnroll={() => enrollMutation.mutate(course.id)}
/>
))}
</div>
);
}
3. Conclusion
Context prend en charge les variables globales intégrées telles que theme, auth et player. RTK gère l’état d’entreprise basé sur des tranches à l’aide de thunks et de RTK Query. Zustand permet de créer des stores avec peu de code tout en assurant la persistance des données. Le cadre de décision mentionné ci-dessus permet de choisir parmi eux, et le modèle combiné conserve TanStack Query pour gérer l’état serveur, tandis que Zustand ou Redux s’occupent de l’état client.
Commencez par utiliser des variables globales limitées dans Context, passez à Zustand ou RTK lorsque des sélecteurs plus ciblés sont nécessaires, et conservez les caches réseau dans TanStack Query plutôt que dans ces stores clients.