본문으로 건너뛰기
Advertisement

상태 관리 — 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>
);
}

세 라이브러리 비교 및 선택 기준

항목ZustandJotaiRecoil
번들 크기~1KB~3KB~21KB
보일러플레이트매우 적음적음중간
학습 곡선낮음낮음중간
데브툴스Redux DevTools 연동Jotai DevToolsRecoil 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 QuerySWR로 관리하는 것이 권장됩니다.

Advertisement