Contents
see List개요
Redux의 복잡성에 지친 개발자들을 위해 등장한 Zustand와 Jotai는 모두 경량 상태관리 라이브러리입니다. 이 가이드에서는 두 라이브러리의 철학, 사용법, 장단점을 비교하여 프로젝트에 적합한 선택을 돕습니다.
Zustand: 간결한 중앙 집중식 상태관리
핵심 개념
- 단일 스토어: Redux처럼 중앙 집중식 상태 관리
- 보일러플레이트 최소화: Provider 없이 바로 사용 가능
- 불변성 자동 처리: Immer 통합으로 편리한 상태 업데이트
- 미들웨어 지원: Redux DevTools, persist 등 확장 가능
기본 사용법
// store.ts
import { create } from 'zustand';
interface TodoState {
todos: Array<{ id: number; text: string; completed: boolean }>;
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
}));
// TodoList.tsx
import { useTodoStore } from './store';
export function TodoList() {
const todos = useTodoStore((state) => state.todos);
const toggleTodo = useTodoStore((state) => state.toggleTodo);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
}
Immer 미들웨어로 편리한 상태 업데이트
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface UserState {
user: {
profile: {
name: string;
email: string;
};
settings: {
theme: 'light' | 'dark';
};
};
updateName: (name: string) => void;
toggleTheme: () => void;
}
export const useUserStore = create<UserState>()(
immer((set) => ({
user: {
profile: { name: '', email: '' },
settings: { theme: 'light' },
},
// Immer로 직접 수정 가능 (불변성 자동 처리)
updateName: (name) =>
set((state) => {
state.user.profile.name = name;
}),
toggleTheme: () =>
set((state) => {
state.user.settings.theme =
state.user.settings.theme === 'light' ? 'dark' : 'light';
}),
}))
);
Persist 미들웨어로 로컬스토리지 연동
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useAuthStore = create(
persist(
(set) => ({
token: null,
user: null,
login: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{
name: 'auth-storage', // localStorage 키
partialize: (state) => ({ token: state.token }), // 일부만 저장
}
)
);
Jotai: 원자적(Atomic) 상태관리
핵심 개념
- 아톰 기반: Recoil처럼 작은 상태 단위(atom)로 분리
- 상향식 접근: 필요한 만큼만 정의하고 조합
- 파생 상태: 다른 아톰을 참조하는 computed 값 생성
- 서스펜스 통합: 비동기 상태를 React Suspense로 처리
기본 사용법
// atoms.ts
import { atom } from 'jotai';
export const todosAtom = atom<Array<{ id: number; text: string; completed: boolean }>>([]);
export const addTodoAtom = atom(
null, // read
(get, set, text: string) => {
const todos = get(todosAtom);
set(todosAtom, [...todos, { id: Date.now(), text, completed: false }]);
}
);
export const toggleTodoAtom = atom(
null,
(get, set, id: number) => {
const todos = get(todosAtom);
set(
todosAtom,
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
);
// 파생 상태 (computed)
export const completedTodosAtom = atom((get) => {
const todos = get(todosAtom);
return todos.filter((todo) => todo.completed);
});
export const todoStatsAtom = atom((get) => {
const todos = get(todosAtom);
return {
total: todos.length,
completed: todos.filter((t) => t.completed).length,
pending: todos.filter((t) => !t.completed).length,
};
});
// TodoList.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { todosAtom, toggleTodoAtom, todoStatsAtom } from './atoms';
export function TodoList() {
const todos = useAtomValue(todosAtom);
const toggleTodo = useSetAtom(toggleTodoAtom);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
}
export function TodoStats() {
const stats = useAtomValue(todoStatsAtom);
return <div>완료: {stats.completed}/{stats.total}</div>;
}
비동기 아톰과 Suspense
import { atom } from 'jotai';
// 비동기 데이터 페칭
export const userAtom = atom(async () => {
const response = await fetch('/api/user');
return response.json();
});
// 파생 비동기 아톰
export const postsAtom = atom(async (get) => {
const user = await get(userAtom);
const response = await fetch(`/api/users/${user.id}/posts`);
return response.json();
});
// UserProfile.tsx
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';
function UserProfile() {
const user = useAtomValue(userAtom); // Suspense 경계 내에서 사용
return <div>{user.name}</div>;
}
export function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
}
아톰 패밀리 (동적 아톰 생성)
import { atomFamily } from 'jotai/utils';
// ID별로 동적으로 아톰 생성
export const todoAtomFamily = atomFamily((id: number) =>
atom({
id,
text: '',
completed: false,
})
);
// 사용
function TodoItem({ id }: { id: number }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<div>
<input
value={todo.text}
onChange={(e) => setTodo({ ...todo, text: e.target.value })}
/>
</div>
);
}
비교 분석
번들 크기
- Zustand: ~1.2KB (gzipped)
- Jotai: ~2.9KB (gzipped)
학습 곡선
- Zustand: 매우 낮음. Redux 경험이 있다면 즉시 사용 가능
- Jotai: 낮음. 아톰 개념 이해 후 직관적
사용 사례별 추천
| 상황 | Zustand | Jotai |
|---|---|---|
| 전역 상태가 많고 복잡한 앱 | ⭐⭐⭐ | ⭐⭐ |
| 작은 독립적인 상태들 | ⭐⭐ | ⭐⭐⭐ |
| 비동기 데이터 페칭 | ⭐⭐ | ⭐⭐⭐ |
| Redux DevTools 필요 | ⭐⭐⭐ | ⭐ |
| 서버 상태 관리 | ⭐⭐ | ⭐⭐⭐ |
| 최소 번들 크기 | ⭐⭐⭐ | ⭐⭐ |
활용 팁
- Zustand는 중앙 집중식 상태가 필요한 대규모 앱에 적합합니다.
- Jotai는 상태를 작은 단위로 분리하고 조합하기 좋습니다.
- Zustand의 미들웨어 생태계를 활용하면 다양한 기능을 쉽게 추가할 수 있습니다.
- Jotai는 React Suspense와 자연스럽게 통합되어 비동기 상태 관리가 편리합니다.
- 두 라이브러리 모두 React Context보다 성능이 우수합니다.
마무리
Zustand와 Jotai는 각각의 철학과 강점을 가진 훌륭한 상태관리 라이브러리입니다. 프로젝트의 규모, 상태 구조, 팀의 선호도를 고려하여 선택하면 됩니다. 작은 프로젝트라면 둘 다 충분히 좋은 선택이며, 복잡도가 증가할수록 각자의 장점이 더 명확해집니다.