Patrones de Gestión de Estado en Frontend - Filosofía de Diseño de Redux, Zustand, Jotai y Recoil

Avanzado | 2025.12.02

La Necesidad de la Gestión de Estado

A medida que las aplicaciones frontend se vuelven más complejas, compartir estado entre componentes se convierte en un desafío. La elección del patrón de gestión de estado adecuado impacta directamente en la mantenibilidad y escalabilidad de la aplicación.

flowchart TB
    subgraph PropDrilling["Prop Drilling (El infierno del paso de props)"]
        App["App<br/>state: { user, theme, cart }"]
        Layout["Layout<br/>← props: user, theme"]
        Header["Header<br/>← props: user, theme"]
        Avatar["Avatar<br/>← props: user (finalmente se usa)"]

        App --> Layout --> Header --> Avatar
    end

    Note["Problema: Los componentes intermedios solo pasan props innecesarios"]

Clasificación de Arquitecturas de Gestión de Estado

flowchart TB
    subgraph Patterns["Clasificación de Patrones de Gestión de Estado"]
        subgraph Flux["1. Patrón Flux/Redux"]
            F1["Store único + Action + Reducer<br/>Redux, Zustand"]
        end

        subgraph Atomic["2. Patrón Atomic"]
            A1["Unidades de estado pequeñas y distribuidas (Atom)<br/>Jotai, Recoil"]
        end

        subgraph Proxy["3. Patrón Proxy-based"]
            P1["Seguimiento automático mediante Proxy<br/>Valtio, MobX"]
        end

        subgraph Signal["4. Patrón Signal"]
            S1["Reactividad de grano fino<br/>Solid.js Signals, Preact Signals"]
        end
    end

Patrón Flux/Redux

Conceptos Básicos

flowchart LR
    View["View"] -->|Action| Dispatcher["Dispatcher"]
    Dispatcher -->|dispatch| Store["Store<br/>(Reducer)"]
    Store -->|notify| State["State<br/>(solo lectura)"]
    State --> View

Principios:

  • Flujo de datos unidireccional
  • El estado es de solo lectura (Inmutable)
  • Los cambios solo se realizan a través de Actions
  • Los Reducers son funciones puras

Implementación con Redux

// store/userSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserState {
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;
}

const initialState: UserState = {
  currentUser: null,
  isLoading: false,
  error: null,
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    loginStart: (state) => {
      state.isLoading = true;
      state.error = null;
    },
    loginSuccess: (state, action: PayloadAction<User>) => {
      state.currentUser = action.payload;
      state.isLoading = false;
    },
    loginFailure: (state, action: PayloadAction<string>) => {
      state.error = action.payload;
      state.isLoading = false;
    },
    logout: (state) => {
      state.currentUser = null;
    },
  },
});

export const { loginStart, loginSuccess, loginFailure, logout } = userSlice.actions;
export default userSlice.reducer;

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false,
    }),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// hooks/useAppDispatch.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from '../store';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

// components/UserProfile.tsx
function UserProfile() {
  const { currentUser, isLoading, error } = useAppSelector((state) => state.user);
  const dispatch = useAppDispatch();

  const handleLogout = () => {
    dispatch(logout());
  };

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!currentUser) return <div>Please login</div>;

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
}

Implementación con Zustand

// store/useStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserStore {
  // State
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;

  // Actions
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => void;
}

export const useUserStore = create<UserStore>()(
  devtools(
    persist(
      immer((set, get) => ({
        currentUser: null,
        isLoading: false,
        error: null,

        login: async (email, password) => {
          set((state) => {
            state.isLoading = true;
            state.error = null;
          });

          try {
            const response = await fetch('/api/auth/login', {
              method: 'POST',
              body: JSON.stringify({ email, password }),
            });
            const user = await response.json();

            set((state) => {
              state.currentUser = user;
              state.isLoading = false;
            });
          } catch (error) {
            set((state) => {
              state.error = error.message;
              state.isLoading = false;
            });
          }
        },

        logout: () => {
          set((state) => {
            state.currentUser = null;
          });
        },

        updateProfile: (updates) => {
          set((state) => {
            if (state.currentUser) {
              Object.assign(state.currentUser, updates);
            }
          });
        },
      })),
      { name: 'user-storage' }
    ),
    { name: 'UserStore' }
  )
);

// Selector (optimización de rendimiento)
export const useCurrentUser = () => useUserStore((state) => state.currentUser);
export const useIsLoading = () => useUserStore((state) => state.isLoading);

// components/UserProfile.tsx
function UserProfile() {
  const currentUser = useCurrentUser();
  const logout = useUserStore((state) => state.logout);

  if (!currentUser) return null;

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Patrón Atomic

Implementación con Jotai

// atoms/userAtoms.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

interface User {
  id: string;
  name: string;
  email: string;
}

// Atom básico
export const currentUserAtom = atom<User | null>(null);

// Atom persistido
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');

// Atom derivado (solo lectura)
export const isLoggedInAtom = atom((get) => get(currentUserAtom) !== null);

// Atom derivado (lectura/escritura)
export const userNameAtom = atom(
  (get) => get(currentUserAtom)?.name ?? 'Guest',
  (get, set, newName: string) => {
    const user = get(currentUserAtom);
    if (user) {
      set(currentUserAtom, { ...user, name: newName });
    }
  }
);

// Atom asíncrono
export const userProfileAtom = atom(async (get) => {
  const user = get(currentUserAtom);
  if (!user) return null;

  const response = await fetch(`/api/users/${user.id}/profile`);
  return response.json();
});

// Atom de acción
export const loginAtom = atom(
  null,
  async (get, set, { email, password }: { email: string; password: string }) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    const user = await response.json();
    set(currentUserAtom, user);
  }
);

export const logoutAtom = atom(null, (get, set) => {
  set(currentUserAtom, null);
});

// components/UserProfile.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai';

function UserProfile() {
  const currentUser = useAtomValue(currentUserAtom);
  const [userName, setUserName] = useAtom(userNameAtom);
  const logout = useSetAtom(logoutAtom);

  if (!currentUser) return null;

  return (
    <div>
      <input
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Implementación con Recoil

// atoms/userState.ts
import { atom, selector, selectorFamily } from 'recoil';

interface User {
  id: string;
  name: string;
  email: string;
}

// Atom
export const currentUserState = atom<User | null>({
  key: 'currentUser',
  default: null,
});

// Selector (estado derivado)
export const isLoggedInState = selector({
  key: 'isLoggedIn',
  get: ({ get }) => get(currentUserState) !== null,
});

// Selector asíncrono
export const userProfileState = selector({
  key: 'userProfile',
  get: async ({ get }) => {
    const user = get(currentUserState);
    if (!user) return null;

    const response = await fetch(`/api/users/${user.id}/profile`);
    return response.json();
  },
});

// Selector con parámetros (SelectorFamily)
export const userPostsState = selectorFamily({
  key: 'userPosts',
  get: (userId: string) => async () => {
    const response = await fetch(`/api/users/${userId}/posts`);
    return response.json();
  },
});

// components/UserProfile.tsx
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';

function UserProfile() {
  const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
  const isLoggedIn = useRecoilValue(isLoggedInState);
  const profile = useRecoilValue(userProfileState);

  const logout = () => setCurrentUser(null);

  if (!isLoggedIn) return <div>Please login</div>;

  return (
    <div>
      <h1>{currentUser?.name}</h1>
      <p>{profile?.bio}</p>
      <button onClick={logout}>Logout</button>
    </div>
  );
}
flowchart TB
    subgraph FluxPattern["Flux (Redux/Zustand)"]
        Store["Single Store<br/>user | cart | ui | ..."]
        Store --> CA["Component A"]
        Store --> CB["Component B"]
        Store --> CC["Component C"]
    end

    subgraph AtomicPattern["Atomic (Jotai/Recoil)"]
        A1["Atom"] --> CompA["Component A"]
        A2["Atom"] --> Derived["Derived Atom"]
        A3["Atom"] --> Derived
        Derived --> CompB["Component B"]
        A4["Atom"] --> CompC["Component C"]
    end

Características:

  • Flux: Centralizado, predecible, fácil de depurar
  • Atomic: Distribuido, grano fino, adecuado para code splitting

Patrón Proxy-based

Implementación con Valtio

// store/userStore.ts
import { proxy, useSnapshot } from 'valtio';
import { devtools } from 'valtio/utils';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserStore {
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;
}

export const userStore = proxy<UserStore>({
  currentUser: null,
  isLoading: false,
  error: null,
});

// Habilitar DevTools
devtools(userStore, { name: 'userStore' });

// Acciones (modificación directa posible)
export const userActions = {
  async login(email: string, password: string) {
    userStore.isLoading = true;
    userStore.error = null;

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      userStore.currentUser = await response.json();
    } catch (error) {
      userStore.error = error.message;
    } finally {
      userStore.isLoading = false;
    }
  },

  logout() {
    userStore.currentUser = null;
  },

  updateName(name: string) {
    if (userStore.currentUser) {
      userStore.currentUser.name = name;  // Modificación directa posible
    }
  },
};

// components/UserProfile.tsx
function UserProfile() {
  // Obtener snapshot de solo lectura con useSnapshot
  const snap = useSnapshot(userStore);

  if (snap.isLoading) return <div>Loading...</div>;
  if (!snap.currentUser) return null;

  return (
    <div>
      <input
        value={snap.currentUser.name}
        onChange={(e) => userActions.updateName(e.target.value)}
      />
      <button onClick={userActions.logout}>Logout</button>
    </div>
  );
}

Comparación de Bibliotecas de Gestión de Estado

CaracterísticaRedux ToolkitZustandJotaiRecoilValtio
Tamaño del bundleGrandePequeñoPequeñoMedioPequeño
Curva de aprendizajeAltaBajaBajaMediaBaja
BoilerplateMuchoPocoPocoMedioPoco
DevToolsCompletoDisponibleDisponibleDisponibleDisponible
TypeScriptBuenoExcelenteExcelenteBuenoExcelente
Soporte SSRBuenoBuenoExcelenteComplejoBueno
Uso fuera de ReactPosiblePosibleNoNoPosible

Criterios de Selección

Guía de Selección de Bibliotecas de Gestión de Estado

Escala del proyecto y equipo:

EscalaRecomendadoRazón
Grande, muchas personasRedux ToolkitConvenciones claras, ecosistema rico
Mediano, pocas personasZustandSimple, bajo costo de aprendizaje
Pequeño, prototipoJotai/ValtioConfiguración mínima

Requisitos de arquitectura:

RequisitoRecomendado
Prioridad en predictibilidadRedux/Zustand
Actualizaciones de grano finoJotai/Recoil
Operaciones mutablesValtio
Server ComponentsJotai (óptimo)

Mejores Prácticas

// 1. Normalización del estado
interface NormalizedState {
  users: {
    byId: Record<string, User>;
    allIds: string[];
  };
  posts: {
    byId: Record<string, Post>;
    allIds: string[];
  };
}

// 2. Aprovechamiento del estado derivado
const selectUserPosts = (state: RootState, userId: string) =>
  state.posts.allIds
    .map(id => state.posts.byId[id])
    .filter(post => post.authorId === userId);

// 3. Separación del procesamiento asíncrono
// Estado del servidor con TanStack Query / SWR
// Estado del cliente con Zustand / Jotai

// 4. División en granularidad apropiada
// NG: Un solo store gigante
// OK: Stores divididos por funcionalidad

Enlaces de Referencia

← Volver a la lista