Контекст, Redux Toolkit и Zustand для управления состоянием в React
Сравните Context, Redux Toolkit и Zustand для видеопортала, затем объедините клиентские хранилища с TanStack Query для управления серверным состоянием.
1. Введение
Выбор способа управления состоянием в React является одним из архитектурных решений, последствия ошибочного выбора которого проявляются со временем. Для изолированного интерфейса достаточно локального использования useState, однако приложения быстро нуждаются в совместном управлении авторизацией, темой, статусом плеера и кэшем на сервере. Набор инструментов для решения этих задач развивался вместе с самим React.
В ранних версиях React использовался локальный состояние и механизм передачи параметров через пропсы. Flux (2014), а затем Redux (2015) сделали использование единого предсказуемого хранилища с редьюсерами стандартом для крупных команд. MobX (2015) предложил альтернативу в виде объектов-наблюдаемых. В React 16.3 (2018) появился современный API Context, позволяющий передавать глобальные значения без внешних библиотек. В React 16.8 (2019) был добавлен useReducer для реализации переходов в стиле Redux прямо внутри React. Zustand (2019) сократил количество кода за счёт использования хранилища, основанного на хуксах. Jotai (2020) и Recoil (2020) внедрили модели атомарного состояния и производных значений. TanStack Query (ранее React Query, 2020) отделил задачи, связанные с серверным состоянием — кэширование, пагинацию, мутации — от состояния пользовательского интерфейса.
Пример рабочей карты выглядит так:
- Локальный
useState— флаги и значения интерфейса, которые никогда не покидают один компонент.
useReducer + Context — сложные переходы, которые всё равно должны оставаться в React.Пример видеопортала использует те же области — тема, аутентификация, курсы, плеер — в рамках Context, Redux Toolkit и Zustand, а затем объединяет их с TanStack Query.
2. Концепции с подробным объяснением
Context API — встроенное глобальное состояние
Context передаёт значения по дереву без использования threading props. Он встроен в React, не требует дополнительных пакетов и подходит для обновлений средней частоты, таких как тема, аутентификация, язык и фич-флаги.
Эта схема состоит из трёх компонентов: createContext() создаёт контекст (с опциональными настройками по умолчанию), Context.Provider публикует значение value, а useContext(Context) считывает его в потомках. Когда значение провайдера меняется, все потребители перерисовываются.
Эта стоимость широковещания имеет значение. Потребители перерисовываются даже в том случае, если им нужно только одно поле. Способы снижения нагрузки включают разделение контекстов по частоте обновлений, мемоизацию значений провайдера с помощью useMemo или использование фильтров потребителей, таких как use-context-selector.
Видеопортал может хранить тему, информацию об аутентификации и состояние плеера в отдельных контекстах:
// 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;
}
Провайдеры находятся у корня структуры разметки:
// 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>
);
}
Консуматоры остаются простыми:
// 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>
);
}
Для редких обновлений достаточно простой структуры контекста. Однако при частом обновлении состояния или сложной иерархии элементов обычно требуется хранилище с более узкими критериями выбора.
Redux Toolkit для крупных скоординированных хранилищ
RTK — это инструментарий, поддерживаемый для современных приложений на Redux. Он устраняет традиционные сложности и предоставляет инструменты для настройки хранилища, определения его компонентов, написания асинхронных функций и получения данных с помощью RTK Query.
Основные элементы строительства:
- Slices — Редьюсеры и действия размещаются в одном файле с помощью функции
createSlice. createAsyncThunk— Стандартные шаблоны обработки асинхронных операций: pending/fulfilled/rejected.
RTK подходит для крупных кодовых баз с запутанными взаимодействиями состояний, большим количеством участников разработки, отладкой с использованием механизма временных путешествий, сложными требованиями к middleware и необходимостью строго предсказуемых обновлений.
Магазин видеопорталов может связывать курсы, плеер, настройки и фрагмент 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;
Затем компоненты отправляют команды и выбирают данные с помощью типизированных хуков:
// 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>
);
}
Структура RTK хорошо масштабируется и документируется; недостатком является большее количество формальностей по сравнению с Context или Zustand для небольших интерфейсов.
Zustand — Минимальное управление состоянием с минимумом шаблонного кода
Zustand предназначен для команд, которые хотят использовать семантику хранилищ данных без всей сложности Redux — особенно когда производительность и простота API важнее формального словаря действий.
С помощью функций persist, DevTools и соответствующего промежуточного программного обеспечения портал может разделять задачи, связанные с курсом и игроками, между хранилищами Zustand:
// 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,
}));
Компоненты читают лишь ограниченные фрагменты состояния хранилища:
// 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 остается компактным, при этом поддерживая функции сохранения данных и выборочной отрисовки. Он является хорошим стандартным решением, когда сложность Redux превышает его преимущества.
Сравнение и рамки принятия решений
Выбирайте в зависимости от частоты обновлений, размера команды и того, принадлежат ли данные серверу или клиенту. Используйте Context для редких глобальных данных; RTK — когда доминируют корпоративные инструменты и промежуточное программное обеспечение; Zustand — когда достаточно простого API хранилища; TanStack Query — всегда, когда источником правды является сеть.
Матрица принятия решений
Соотнесите инструмент с ограничениями: кривая обучения, шаблонный код, DevTools, глубина промежуточного слоя и количество пользователей, влияющих на одно и то же состояние. Выбирайте наименее сложный вариант, который позволяет сохранять предсказуемость обновлений при заданном размере команды.
Сравнение производительности
Context перерисовывается при изменении значений. Redux и Zustand могут применять более узкую связь. Оцените поведение интерфейса при прокрутке и фильтрации списка в условиях, максимально приближенных к реальным, прежде чем делать окончательный выбор.
Интеграция с TanStack Query
Рекомендуемое разделение:
- TanStack Query — состояние сервера (кэш, фоновое обновление, мутации).
- Zustand или Redux — состояние пользовательского интерфейса (настройки, временный интерфейс игрока).
- Context — глобальные переменные, меняющиеся редко.
// 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. Заключение
Контекст используется для хранения встроенных глобальных данных, таких как тема, авторизация и плеер. RTK предназначен для управления состоянием корпоративных приложений на основе фрагментов с использованием thunks и RTK Query. Zustand позволяет создавать простые хранилища данных с возможностью сохранения состояния. Вышеописанная система принятия решений помогает выбрать подходящий инструмент: TanStack Query отвечает за серверное состояние, тогда как Zustand или Redux управляют клиентским состоянием.
Начните с простых глобальных данных в контексте, перейдите к Zustand или RTK там, где необходимы более точные селекторы, и используйте кэши сети в TanStack Query вместо клиентских хранилищ.