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
| Pattern | Description | Examples |
|---|---|---|
| Flux/Redux | Single Store + Action + Reducer | Redux, Zustand |
| Atomic | Distributed small state units (Atoms) | Jotai, Recoil |
| Proxy-based | Automatic tracking via Proxy | Valtio, MobX |
| Signal | Fine-grained reactivity | Solid.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>
);
}
| Pattern | Architecture | Characteristics |
|---|---|---|
| Flux (Redux/Zustand) | Single Store → Components | Centralized, predictable, easy to debug |
| Atomic (Jotai/Recoil) | Multiple Atoms → Derived Atoms → Components | Distributed, 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
| Feature | Redux Toolkit | Zustand | Jotai | Recoil | Valtio |
|---|---|---|---|---|---|
| Bundle size | Large | Small | Small | Medium | Small |
| Learning curve | High | Low | Low | Medium | Low |
| Boilerplate | Much | Little | Little | Medium | Little |
| DevTools | Rich | Available | Available | Available | Available |
| TypeScript | Good | Excellent | Excellent | Good | Excellent |
| SSR support | Good | Good | Excellent | Complex | Good |
| Use outside React | Possible | Possible | Not possible | Not possible | Possible |
Selection Criteria
Project Scale / Team:
| Scale | Recommendation | Reason |
|---|---|---|
| Large-scale / many people | Redux Toolkit | Clear conventions, rich ecosystem |
| Medium-scale / small team | Zustand | Simple, low learning curve |
| Small-scale / prototype | Jotai/Valtio | Minimal setup |
Architecture Requirements:
| Requirement | Recommendation |
|---|---|
| Predictability focused | Redux/Zustand |
| Fine-grained updates | Jotai/Recoil |
| Mutable operations | Valtio |
| Server Components | Jotai (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