Inicio / Artículos / Contexto vs Redux Toolkit vs Zustand para el estado en React

Contexto vs Redux Toolkit vs Zustand para el estado en React

Compare Context, Redux Toolkit y Zustand para un portal de videos, y luego combine las tiendas del cliente con TanStack Query para el estado del servidor.

7272 palabras

1. Introducción

Elegir cómo se gestionará el estado en React es una de las decisiones arquitectónicas que tienen graves consecuencias cuando se toman incorrectamente. El uso local de useState es suficiente para interfaces de usuario aisladas, pero las aplicaciones pronto necesitan mecanismos compartidos para la autenticación, los temas, el estado del reproductor y las cachés del servidor. La herramienta para llevar a cabo esta tarea ha ido evolucionando junto con React mismo.

En las primeras versiones de React se dependía del estado local y de la transmisión manual de propiedades. Flux (2014) y posteriormente Redux (2015) establecieron como estándar para equipos grandes un almacén único y predecible con reducers. MobX (2015) ofreció observables como alternativa más ligera. React 16.3 (2018) incluyó una API Context moderna que permitía gestionar valores globales sin necesidad de bibliotecas externas. React 16.8 (2019) añadió useReducer para realizar transiciones similares a las de Redux dentro de React. Zustand (2019) redujo la cantidad de código genérico mediante un almacén basado en hooks. Jotai (2020) y Recoil (2020) introdujeron modelos de estado atómico y derivado. TanStack Query (anteriormente React Query, 2020) separó las tareas relacionadas con el estado del servidor —como caché, paginación y mutaciones— del estado de la interfaz de usuario.

Un mapa funcional se ve así:

  • useState local — indicadores y valores de la interfaz que nunca salen de un componente.
  • Contexto — Aplicaciones de tamaño medio con valores globales de baja frecuencia (tema, autenticación, configuración regional).
  • useReducer + Contexto — Transiciones complejas que aún deben mantenerse dentro de React.
  • Redux Toolkit — Equipos grandes, middleware intensivo, DevTools y flujos de trabajo empresariales.
  • Zustand — Aplicaciones de tamaño medio a grande que buscan una API sencilla y un rendimiento elevado.
  • TanStack Query — Estado del servidor, independientemente del almacén de cliente que se elija.
  • Un boceto de portal de videos aplica los mismos dominios —tema, autenticación, cursos, reproductor— bajo Contexto, Redux Toolkit y Zustand, y luego los combina con TanStack Query.

    2. Conceptos con explicación detallada

    API de Contexto — Estado global integrado

    Context transmite valores a través del árbol sin utilizar props en hilos separados. Está integrado en React, no requiere ningún paquete y es adecuado para actualizaciones de frecuencia media como el tema, la autenticación, el idioma y las banderas de funcionalidad.

    Tres componentes conforman este patrón: createContext() crea el contexto (con valor por defecto opcional), Context.Provider publica un value, y useContext(Context) lo lee en los descendientes. Cuando el valor del proveedor cambia, todos los consumidores se vuelven a renderizar.

    Ese costo de difusión es importante. Los consumidores se vuelven a renderizar incluso si solo necesitan un campo. Las soluciones incluyen dividir los contextos según la tasa de actualización, memorizar los valores del proveedor con useMemo o utilizar ayudantes selectivos para consumidores como use-context-selector.

    Un portal de videos puede mantener el tema, la autenticación y el estado del reproductor en contextos separados:

    // 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;
    }
    

    Los proveedores se componen en la raíz del diseño:

    // 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>
      );
    }
    

    Los consumidores permanecen simples:

    // 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>
      );
    }
    

    El contexto es fácil de adoptar y suficiente cuando las actualizaciones son infrecuentes. Los relojes de reproducción de alta frecuencia o los árboles con muchas ramificaciones suelen requerir un almacén con selectores más específicos.

    Redux Toolkit para almacenes grandes y coordinados

    RTK es la herramienta soportada para las aplicaciones Redux actuales. Elimina los procedimientos tradicionales e incluye ayudantes para configurar el almacén, definir slices, escribir thunks asíncronos y realizar consultas con RTK Query.

    Elementos básicos de construcción:

    • Slices — Se colocan los reducers y las acciones en un mismo archivo mediante createSlice.
    • createAsyncThunk — Flujos estándar de pendiente/completado/rechazado para tareas asíncronas.
  • RTK Query — Obtención de datos y caché integrados con la tienda.
  • Selectors — Lecturas derivadas y memorizadas del estado.
  • Middleware — Thunk (incluido) y registro de logs u otros mejoradores según sea necesario.
  • RTK es adecuado para grandes bases de código con interacciones complejas entre estados, muchos colaboradores, depuración mediante “time-travel”, necesidades avanzadas de middleware y una preferencia por actualizaciones estrictamente predecibles.

    Una tienda de portales de video podría conectar cursos, reproductores, preferencias y una porción de 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;
    

    Luego, los componentes envían solicitudes y realizan selecciones a través de hooks tipados:

    // components/CourseListRedux.tsx - React component using Redux
    'use client';
    
    import { useEffect } from 'react';
    import { useAppDispatch, useAppSelector } from '@/store/hooks';
    import { fetchCourses, setFilters, setPage } from '@/store/slices/coursesSlice';
    import { useGetCoursesQuery } from '@/store/services/apiSlice';
    import { RootState } from '@/store';
    
    export function CourseListRedux() {
      // Using traditional Redux slices
      const dispatch = useAppDispatch();
      const { items: courses, isLoading, filters, pagination } = useAppSelector(
        (state: RootState) => state.courses
      );
    
      // Using RTK Query for automatic caching
      const { data: apiCourses, isLoading: apiLoading } = useGetCoursesQuery({
        page: pagination.page,
        category: filters.category || undefined,
        sort: filters.sortBy,
      });
    
      useEffect(() => {
        dispatch(fetchCourses({
          page: pagination.page,
          category: filters.category || undefined,
          sortBy: filters.sortBy,
        }));
      }, [dispatch, pagination.page, filters.category, filters.sortBy]);
    
      const handleFilterChange = (category: string | null) => {
        dispatch(setFilters({ category }));
      };
    
      const handleSortChange = (sortBy: typeof filters.sortBy) => {
        dispatch(setFilters({ sortBy }));
      };
    
      if (isLoading) {
        return <div className="flex justify-center p-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>;
      }
    
      return (
        <div className="container mx-auto px-4 py-8">
          {/* Filters */}
          <div className="mb-6 flex gap-4">
            <select
              value={filters.category || ''}
              onChange={(e) => handleFilterChange(e.target.value || null)}
              className="px-3 py-2 border rounded-lg"
            >
              <option value="">All Categories</option>
              <option value="react">React</option>
              <option value="typescript">TypeScript</option>
              <option value="nextjs">Next.js</option>
            </select>
    
            <select
              value={filters.sortBy}
              onChange={(e) => handleSortChange(e.target.value as any)}
              className="px-3 py-2 border rounded-lg"
            >
              <option value="newest">Newest</option>
              <option value="popular">Most Popular</option>
              <option value="rating">Highest Rated</option>
              <option value="price_low">Price: Low to High</option>
              <option value="price_high">Price: High to Low</option>
            </select>
          </div>
    
          {/* Course Grid */}
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {courses.map((course) => (
              <CourseCardRedux key={course.id} course={course} />
            ))}
          </div>
    
          {/* Pagination */}
          {pagination.totalPages > 1 && (
            <div className="mt-8 flex justify-center gap-2">
              {Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((p) => (
                <button
                  key={p}
                  onClick={() => dispatch(setPage(p))}
                  className={`px-3 py-1 rounded ${
                    p === pagination.page
                      ? 'bg-blue-600 text-white'
                      : 'bg-gray-200 hover:bg-gray-300'
                  }`}
                >
                  {p}
                </button>
              ))}
            </div>
          )}
        </div>
      );
    }
    
    function CourseCardRedux({ course }: { course: Course }) {
      const [enroll] = useEnrollInCourseMutation();
    
      return (
        <div className="bg-white rounded-lg shadow-sm overflow-hidden">
          <img src={course.thumbnail} alt={course.title} className="w-full h-48 object-cover" />
          <div className="p-4">
            <h3 className="font-semibold text-lg">{course.title}</h3>
            <p className="text-sm text-gray-600 mt-1">{course.instructor.name}</p>
            <div className="flex items-center gap-2 mt-2">
              <span className="text-yellow-500">★</span>
              <span>{course.rating}</span>
              <span className="text-gray-400">•</span>
              <span>{course.enrolledCount.toLocaleString()} students</span>
            </div>
            <div className="mt-3 flex justify-between items-center">
              <span className="text-xl font-bold">${course.price}</span>
              {!course.isEnrolled ? (
                <button
                  onClick={() => enroll(course.id)}
                  className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
                >
                  Enroll
                </button>
              ) : (
                <span className="px-4 py-2 bg-green-100 text-green-700 rounded">Enrolled ✓</span>
              )}
            </div>
          </div>
        </div>
      );
    }
    

    La estructura de RTK se escala y documenta bien; el trade-off es que requiere más formalismos que Context o Zustand para pantallas pequeñas.

    Zustand — Gestión de estado con mínimo código base

    Zustand está dirigido a equipos que desean semántica de almacenes sin las complejidades de Redux, especialmente cuando el rendimiento y una API sencilla son más importantes que un vocabulario formal de acciones.

    Un portal puede distribuir las necesidades relacionadas con los cursos y los jugadores entre varios almacenes de Zustand, gracias a funciones como persist, DevTools y middleware relacionados:

    // 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,
    }));
    

    Los componentes leen solo las partes específicas del estado del almacén:

    // 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 mantiene un tamaño reducido al tiempo que permite la persistencia y las renderizaciones selectivas. Es una opción ideal cuando las complejidades de Redux superan sus ventajas.

    Comparación y marco de toma de decisiones

    Elija según la frecuencia de actualización, el tamaño del equipo y si los datos pertenecen al servidor o al cliente. Utilice context para globales escasos; RTK cuando predominen las herramientas empresariales y el middleware; Zustand cuando basta con una API de almacén muy simple; TanStack Query siempre que la fuente de verdad sea la red.

    Matriz de decisiones

    Asigne la herramienta según las limitaciones: curva de aprendizaje, código genérico, DevTools, profundidad del middleware y la cantidad de usuarios que modifican el mismo estado. Elija la opción más sencilla que permita mantener las actualizaciones predecibles según el tamaño de su equipo.

    Comparación de rendimiento

    El contexto se vuelve a renderizar principalmente ante cambios en los valores. Redux y Zustand pueden vincularse de manera más específica. Mida el desplazamiento del reproductor y los filtros de la lista en condiciones similares a producción antes de tomar una decisión.

    Integración con TanStack Query

    Una división eficaz:

    • TanStack Query — Estado del servidor (caché, actualizaciones en segundo plano, mutaciones).
    • Zustand o Redux — Estado de la interfaz del cliente (preferencias, interfaz temporal del reproductor).
    • Context — Variables globales que cambian raramente.
    // 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. Conclusión

    El Contexto abarca variables globales integradas como theme, auth y player. RTK se ocupa del estado empresarial basado en slices mediante thunks y RTK Query. Zustand gestiona almacenes con poca sobrecarga y funcionalidad de persistencia. El marco de decisión anterior permite elegir entre ellos, y el patrón combinado mantiene a TanStack Query para el estado del servidor, mientras que Zustand o Redux se encargan del estado del cliente.

    Comience con variables globales simples en Context, pase a Zustand o RTK cuando sean necesarios selectores más específicos, y mantenga las cachés de red en TanStack Query en lugar de esos almacenes del cliente.