상태 관리 — Context API, Zustand, Jotai, Recoil
Context API 한계와 활용
Context API는 React 내장 솔루션으로, 추가 패키지 없이 전역 상태를 공유할 수 있습니다. 하지만 다음 한계가 있습니다.
한계점:
- Context 값이 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다.
- 여러 개의 값을 다루려면 여러 Provider를 중첩해야 합니다.
- DevTools 지원이 없어 디버깅이 어렵습니다.
적합한 사용 사례:
- 테마, 언어 설정처럼 자주 바뀌지 않는 값
- 인증된 사용자 정보
- 모달, 토스트 같은 UI 상태
// Context API 적절한 활용 예시
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
async function login(credentials) {
const userData = await apiLogin(credentials);
setUser(userData);
}
function logout() {
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Zustand
가장 단순하고 가벼운 전역 상태 관리 라이브러리입니다. 보일러플레이트가 거의 없습니다.
설치
npm install zustand
기본 사용법
import { create } from 'zustand';
// 스토어 생성
const useCounterStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// 컴포넌트에서 사용
function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>초기화</button>
</div>
);
}
선택적 구독 (최적화)
// ✅ 필요한 값만 선택적으로 구독 — 다른 값이 바뀌어도 리렌더링 없음
function CountDisplay() {
const count = useCounterStore(state => state.count);
return <p>{count}</p>;
}
function CountActions() {
const increment = useCounterStore(state => state.increment);
return <button onClick={increment}>+</button>;
}
비동기 액션
const useUserStore = create((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const data = await fetch('/api/users').then(r => r.json());
set({ users: data, loading: false });
} catch (err) {
set({ error: err.message, loading: false });
}
},
addUser: (user) => {
set(state => ({ users: [...state.users, user] }));
},
removeUser: (id) => {
set(state => ({ users: state.users.filter(u => u.id !== id) }));
},
}));
Zustand + Immer (불변성 편리하게)
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useStore = create(immer((set) => ({
todos: [],
addTodo: (text) => set(state => {
state.todos.push({ id: Date.now(), text, done: false });
}),
toggleTodo: (id) => set(state => {
const todo = state.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}),
})));
Jotai
원자적(Atomic) 상태 관리 — 상태를 작은 원자(atom) 단위로 관리하고, 필요한 원자만 구독합니다. Recoil에서 영감을 받았지만 더 간결합니다.
설치
npm install jotai
기본 사용법
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// 원자 정의
const countAtom = atom(0);
const textAtom = atom('');
const darkModeAtom = atom(false);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
// 값만 읽기 (리렌더 최적화)
function CountDisplay() {
const count = useAtomValue(countAtom);
return <p>현재 카운트: {count}</p>;
}
// 쓰기만 (리렌더 없음)
function IncrementButton() {
const setCount = useSetAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>증가</button>;
}
파생 원자 (Derived Atom)
// 기본 원자
const priceAtom = atom(10000);
const quantityAtom = atom(1);
// 파생 원자 (읽기 전용)
const totalAtom = atom(get => get(priceAtom) * get(quantityAtom));
// 쓰기 가능 파생 원자
const discountedTotalAtom = atom(
get => get(totalAtom) * 0.9, // 읽기: 10% 할인 적용
(get, set, discount) => { // 쓰기: 수량 조정
const price = get(priceAtom);
set(quantityAtom, Math.floor(discount / price));
}
);
function PriceCalculator() {
const [price, setPrice] = useAtom(priceAtom);
const [quantity, setQuantity] = useAtom(quantityAtom);
const total = useAtomValue(totalAtom);
return (
<div>
<input type="number" value={price} onChange={e => setPrice(Number(e.target.value))} />
<input type="number" value={quantity} onChange={e => setQuantity(Number(e.target.value))} />
<p>합계: {total.toLocaleString()}원</p>
</div>
);
}
Recoil
Facebook이 개발한 상태 관리 라이브러리로, React의 Concurrent Mode를 최대한 활용합니다.
설치
npm install recoil
기본 사용법
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
// 앱 루트에 RecoilRoot 추가
function App() {
return (
<RecoilRoot>
<TodoApp />
</RecoilRoot>
);
}
// Atom 정의
const todoListAtom = atom({
key: 'todoList', // 고유 키 (전체 앱에서 유일해야 함)
default: [],
});
const filterAtom = atom({
key: 'todoFilter',
default: 'all', // 'all' | 'active' | 'completed'
});
// Selector (파생 상태)
const filteredTodosSelector = selector({
key: 'filteredTodos',
get: ({ get }) => {
const todos = get(todoListAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'completed': return todos.filter(t => t.done);
default: return todos;
}
},
});
// 사용
function TodoList() {
const filteredTodos = useRecoilValue(filteredTodosSelector);
const setTodos = useSetRecoilState(todoListAtom);
function toggleTodo(id) {
setTodos(todos => todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
}
return (
<ul>
{filteredTodos.map(todo => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
}
세 라이브러리 비교 및 선택 기준
| 항목 | Zustand | Jotai | Recoil |
|---|---|---|---|
| 번들 크기 | ~1KB | ~3KB | ~21KB |
| 보일러플레이트 | 매우 적음 | 적음 | 중간 |
| 학습 곡선 | 낮음 | 낮음 | 중간 |
| 데브툴스 | Redux DevTools 연동 | Jotai DevTools | Recoil DevTools |
| 원자적 업데이트 | ❌ | ✅ | ✅ |
| SSR 지원 | ✅ | ✅ | 제한적 |
| 유지보수 | Pmndrs 팀 | Pmndrs 팀 | Meta |
| 추천 상황 | 간단·빠른 도입 | 세분화 최적화 | React 생태계 통합 |
선택 가이드:
- 빠르게 도입하고 싶다 → Zustand
- 컴포넌트별 리렌더 최적화가 중요하다 → Jotai
- 복잡한 파생 상태가 많다 → Recoil 또는 Jotai
- 대규모 팀 프로젝트 → Zustand + Immer 또는 Redux Toolkit
실전 예제: 쇼핑 카트 (Zustand)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// localStorage에 자동 저장되는 카트 스토어
const useCartStore = create(
persist(
(set, get) => ({
items: [],
addItem: (product) => {
const items = get().items;
const existing = items.find(i => i.id === product.id);
if (existing) {
set({
items: items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
});
} else {
set({ items: [...items, { ...product, quantity: 1 }] });
}
},
removeItem: (id) =>
set(state => ({ items: state.items.filter(i => i.id !== id) })),
updateQuantity: (id, quantity) =>
set(state => ({
items: quantity <= 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
get total() {
return get().items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
get itemCount() {
return get().items.reduce((sum, i) => sum + i.quantity, 0);
},
}),
{ name: 'cart-storage' } // localStorage 키
)
);
// 장바구니 아이콘
function CartIcon() {
const itemCount = useCartStore(state => state.itemCount);
return (
<button>
🛒 <span>{itemCount}</span>
</button>
);
}
// 상품 카드
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem);
return (
<div>
<h3>{product.name}</h3>
<p>{product.price.toLocaleString()}원</p>
<button onClick={() => addItem(product)}>담기</button>
</div>
);
}
고수 팁
1. Zustand의 얕은 비교
import { shallow } from 'zustand/shallow';
// 여러 값을 선택할 때 shallow 비교로 불필요한 리렌더 방지
function UserInfo() {
const { name, email } = useUserStore(
state => ({ name: state.name, email: state.email }),
shallow
);
return <p>{name} ({email})</p>;
}
2. 스토어 분리 원칙
단일 거대한 스토어 대신 관심사별로 스토어를 분리하세요.
// ✅ 분리된 스토어
const useAuthStore = create(...);
const useCartStore = create(...);
const useUIStore = create(...);
3. 상태 관리 없이 서버 상태는 TanStack Query에 위임
서버에서 불러오는 데이터(사용자 목록, 상품 목록 등)는 상태 관리 라이브러리가 아닌 TanStack Query나 SWR로 관리하는 것이 권장됩니다.