Frontend State Management Patterns - Design Philosophy of Redux, Zustand, Jotai, and Recoil

2025.12.02

The Need for State Management

As frontend applications become more complex, sharing state between components becomes challenging. Choosing the right state management pattern directly impacts application maintainability and scalability.

flowchart TB
    subgraph PropDrilling["Prop Drilling Problem"]
        App["App<br/>state: user, theme, cart"] --> Layout["Layout<br/>props: user, theme"]
        Layout --> Header["Header<br/>props: user, theme"]
        Header --> Avatar["Avatar<br/>props: user (finally used)"]
    end

Problem: Intermediate components just pass down props

State Management Architecture Classification

PatternDescriptionExamples
Flux/ReduxSingle Store + Action + ReducerRedux, Zustand
AtomicDistributed small state units (Atoms)Jotai, Recoil
Proxy-basedAutomatic tracking via ProxyValtio, MobX
SignalFine-grained reactivitySolid.js Signals, Preact Signals

Flux/Redux Pattern

Basic Concepts

flowchart LR
    View["View"] -->|Action| Dispatcher
    Dispatcher -->|dispatch| Store["Store (Reducer)"]
    Store -->|notify| State["State (read-only)"]
    State --> View

Principles:

  • Unidirectional data flow
  • State is read-only (Immutable)
  • Changes only through Actions
  • Reducers are pure functions

Redux Implementation

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

Zustand Implementation

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

Atomic Pattern

Jotai Implementation

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

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

// Basic Atom
export const currentUserAtom = atom<User | null>(null);

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

// Derived Atom (read-only)
export const isLoggedInAtom = atom((get) => get(currentUserAtom) !== null);

// Derived Atom (read-write)
export const userNameAtom = atom(
  (get) => get(currentUserAtom)?.name ?? 'Guest',
  (get, set, newName: string) => {
    const user = get(currentUserAtom);
    if (user) {
      set(currentUserAtom, { ...user, name: newName });
    }
  }
);

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

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

Recoil Implementation

// 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 (derived state)
export const isLoggedInState = selector({
  key: 'isLoggedIn',
  get: ({ get }) => get(currentUserState) !== null,
});

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

// Parameterized Selector (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>
  );
}
PatternArchitectureCharacteristics
Flux (Redux/Zustand)Single Store → ComponentsCentralized, predictable, easy to debug
Atomic (Jotai/Recoil)Multiple Atoms → Derived Atoms → ComponentsDistributed, fine-grained, code-split ready

Proxy-based Pattern

Valtio Implementation

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

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

// Actions (can be modified directly)
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;  // Can be modified directly
    }
  },
};

// components/UserProfile.tsx
function UserProfile() {
  // Get read-only snapshot with 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>
  );
}

State Management Library Comparison

FeatureRedux ToolkitZustandJotaiRecoilValtio
Bundle sizeLargeSmallSmallMediumSmall
Learning curveHighLowLowMediumLow
BoilerplateMuchLittleLittleMediumLittle
DevToolsRichAvailableAvailableAvailableAvailable
TypeScriptGoodExcellentExcellentGoodExcellent
SSR supportGoodGoodExcellentComplexGood
Use outside ReactPossiblePossibleNot possibleNot possiblePossible

Selection Criteria

Project Scale / Team:

ScaleRecommendationReason
Large-scale / many peopleRedux ToolkitClear conventions, rich ecosystem
Medium-scale / small teamZustandSimple, low learning curve
Small-scale / prototypeJotai/ValtioMinimal setup

Architecture Requirements:

RequirementRecommendation
Predictability focusedRedux/Zustand
Fine-grained updatesJotai/Recoil
Mutable operationsValtio
Server ComponentsJotai (optimal)

Best Practices

// 1. State normalization
interface NormalizedState {
  users: {
    byId: Record<string, User>;
    allIds: string[];
  };
  posts: {
    byId: Record<string, Post>;
    allIds: string[];
  };
}

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

// 3. Separate async processing
// Server state: TanStack Query / SWR
// Client state: Zustand / Jotai

// 4. Split at appropriate granularity
// NG: Single huge store
// OK: Stores split by feature
← Back to list