Context, Redux Toolkit und Zustand für den React-State-Management
Vergleichen Sie Context, Redux Toolkit und Zustand für ein Videoportal und kombinieren Sie anschließend die Client-Speicher mit TanStack Query für den Serverzustand.
1. Einführung
Die Entscheidung darüber, wie der Zustand in React verwaltet wird, gehört zu den architektonischen Entscheidungen, die bei Fehlern schnell zu Problemen führen. Für isolierte Benutzeroberflächen reicht useState aus, doch Apps benötigen schnell gemeinsame Authentifizierungsmechanismen, Themen, Spielerstatusinformationen sowie Servercaches. Das Werkzeugset für diese Aufgaben hat sich zusammen mit React selbst weiterentwickelt.
Frühe Versionen von React setzten auf lokalen Zustand und Prop-Drilling. Flux (2014) sowie anschließend Redux (2015) machten einen einzigen, vorhersehbaren Store mit Reduzern zur Standardlösung für große Teams. MobX (2015) bot Observables als leichtere Alternative an. React 16.3 (2018) führte eine moderne Context-API ein, sodass globale Werte ohne externe Bibliotheken übertragen werden konnten. React 16.8 (2019) fügte useReducer hinzu, um innerhalb von React Redux-ähnliche Übergänge zu ermöglichen. Zustand (2019) verringerte den Boilerplate durch einen auf Hooks basierenden Store. Jotai (2020) und Recoil (2020) setzten auf atomare sowie abgeleitete Zustandsmodelle. TanStack Query (früher React Query, 2020) trennte serverseitige Aspekte wie Caching, Paginierung und Mutationen vom clientseitigen UI-Zustand.
Eine funktionierende Karte sieht so aus:
- Lokaler
useState– UI-Flags und -Werte, die niemals ein Komponenten verlassen.
useReducer + Context – Komplexe Übergänge, die dennoch in React verbleiben sollten.Ein Entwurf für ein Video-Portal wendet dieselben Bereiche – Theme, Authentifizierung, Kurse, Player – unter Verwendung von Context, Redux Toolkit und Zustand an und kombiniert sie anschließend mit TanStack Query.
2. Konzepte mit ausführlicher Erklärung
Context API – Im integrierte globaler Zustand
Context überträgt Werte durch den Baum, ohne Props zu verwenden. Er ist in React integriert, erfordert kein Paket und eignet sich für mittelhäufige Aktualisierungen wie Theme, Authentifizierung, Sprache und Feature Flags.
Dieses Muster setzt sich aus drei Komponenten zusammen: createContext() erstellt den Context (optional mit Standardwert), Context.Provider gibt einen value frei, und useContext(Context) liest diesen in den Nachkommen ab. Wenn sich der Wert des Providers ändert, wird jeder Verbraucher neu gerendert.
Diese Übertragungskosten sind von Bedeutung. Verbraucher werden auch dann neu gerendert, wenn sie nur ein Feld benötigen. Möglichkeiten zur Minderung sind die Trennung der Contexts nach Aktualisierungsrate, das Memoisieren der Provider-Werte mit useMemo oder selektive Hilfsfunktionen für Verbraucher wie use-context-selector.
Ein Video-Portal kann Theme, Authentifizierung und Player-Zustand in separaten Contexts speichern:
// 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;
}
Die Anbieter werden am Wurzelpunkt des Layouts erstellt:
// 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>
);
}
Die Verbraucher bleiben einfach strukturiert:
// 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>
);
}
Der Kontext ist leicht anzuwenden und ausreichend, wenn Updates selten erfolgen. Bei häufigen Aktualisierungen oder komplexen Strukturen wird in der Regel ein Store mit eng gefassten Selektoren benötigt.
Redux Toolkit für große, koordinierte Stores
RTK ist das unterstützte Toolkit für aktuelle Redux-Anwendungen. Es beseitigt die klassischen Komplikationen und bietet Hilfsmittel zur Konfiguration des Stores, zur Definition von Slices, zum Schreiben von asynchronen Thunks sowie zum Abrufen von Daten mit RTK Query.
Kernbausteine:
- Slices – Reducer und Actions werden über
createSlicein einer Datei zusammengefasst. createAsyncThunk– Standardisierte Abläufe für wartende, abgeschlossene oder abgelehnte asynchrone Operationen.
RTK eignet sich für große Codebasen mit komplexen Zustandsinteraktionen, vielen Mitwirkern, Debugging-Möglichkeiten im „Zeitreise-Modus“, umfangreichen Middleware-Anforderungen sowie dem Wunsch nach streng vorhersehbaren Updates.
Ein Video-Portal-Store könnte Kurse, Player-Funktionen, Benutzerpräferenzen sowie einen API-Anteil verbinden:
// 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;
Dann leiten die Komponenten Aktionen aus und wählen über typisierte Hooks:
// 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>
);
}
Die Struktur von RTK skaliert gut und ist gut dokumentiert; der Nachteil besteht darin, dass im Vergleich zu Context oder Zustand für kleine Screens mehr Formalitäten erforderlich sind.
Zustand – Minimaler Boilerplate für Zustandsverwaltung
Zustand richtet sich an Teams, die Store-Semantik ohne die Komplexität von Redux wünschen – insbesondere dann, wenn Leistung und eine kurze API wichtiger sind als ein formelles Aktionssprachgebrauch.
Ein Portal kann Kurs- und Spielerbezogene Aspekte über Zustand-Stores mit Funktionen wie persist, DevTools sowie verwandtem Middleware aufteilen:
// 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,
}));
Komponenten lesen nur begrenzte Teile des Store-Zustands:
// 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 bleibt klein, unterstützt aber dennoch Persistenz und selektive Darstellungen. Er ist eine gute Standardlösung, wenn die Komplexität von Redux seine Vorteile überwiegt.
Vergleich und Entscheidungsrahmen
Wählen Sie je nach Aktualisierungsfrequenz, Teamgröße sowie danach, ob die Daten auf dem Server oder beim Client liegen. Nutzen Sie Context für spärliche Globaleinstellungen; RTK, wenn unternehmensweite Tools und Middleware im Vordergrund stehen; Zustand, wenn eine kleine Store-API ausreicht; TanStack Query, wenn die Quelle der Wahrheit das Netzwerk ist.
Entscheidungsmatrix
Passen Sie das Tool an die Einschränkungen an: Lernkurve, Standardvorlagen, DevTools, Tiefe des Middleware-Systems sowie die Anzahl der Nutzer, die denselben Zustand bearbeiten. Wählen Sie die kleinste Option, die bei der Größe Ihres Teams zu vorhersehbaren Updates führt.
Leistungsvergleich
Der Context wird im Allgemeinen bei Wertänderungen neu gerendert. Redux und Zustand können enger gebunden werden. Messen Sie das Verhalten beim Scrollen durch Inhalte sowie die Funktionalität der Listenfilter unter Bedingungen, die einer Produktionsumgebung ähneln, bevor Sie sich für eine Lösung entscheiden.
Integration mit TanStack Query
Eine nachhaltige Aufteilung:
- TanStack Query — Serverzustand (Caching, Hintergrundaktualisierungen, Mutationen).
- Zustand oder Redux — Clientseitiger UI-Zustand (Einstellungen, vorübergehende UI-Elemente des Players).
- Context — Selten ändernde Globaleinstellungen.
// 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. Fazit
Der Kontext umfasst eingebaute Globale wie Theme, Auth und Player. RTK kümmert sich um unternehmensweite Zustände auf Basis von Slices mithilfe von Thunks und RTK Query. Zustand bietet Stores mit geringem Boilerplate sowie Persistenzfunktionen. Das obige Entscheidungsschema wählt zwischen diesen Ansätzen aus, wobei das kombinierte Muster TanStack Query für den Serverzustand verwendet, während Zustand oder Redux den Clientzustand verwalten.
Fangen Sie mit spärlichen Globalen in Context an, wechseln Sie zu Zustand oder RTK, wenn spezifische Selektoren wichtig sind, und nutzen Sie Netzwerk-Caches in TanStack Query anstelle dieser Client-Stores.