Zustand는 Redux의 복잡한 보일러플레이트 없이 전역 상태를 간결하게 관리하는 라이브러리입니다. Provider 래퍼 없이 훅 하나로 어디서든 상태에 접근할 수 있으며, 불필요한 리렌더링을 막기 위한 셀렉터 패턴을 기본으로 지원합니다.
npm install zustand
Store: 전역 상태(State)와 그것을 변경하는 함수(Action)를 하나의 객체로 묶어 관리하는 컨테이너
create 함수에 초기 상태와 액션을 담은 객체를 반환하는 콜백을 넘기면 커스텀 훅이 생성됩니다.
// src/store/useCounterStore.ts
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
export default useCounterStore;
생성된 useCounterStore는 그냥 훅처럼 호출합니다. Provider 설정이 필요 없습니다.
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
State는 create에 전달한 초기 객체에서 함수를 제외한 나머지 값입니다. 직접 변형하지 않고 반드시 set을 통해 새 값으로 교체합니다.
const useUserStore = create<{
name: string;
age: number;
profile: { bio: string };
}>((set) => ({
name: "홍길동",
age: 30,
profile: { bio: "개발자입니다." },
updateName: (name: string) => set({ name }),
// 중첩 객체 업데이트 — 스프레드로 병합
updateBio: (bio: string) =>
set((state) => ({
profile: { ...state.profile, bio },
})),
}));
set은 기본적으로 객체를 얕은 병합(shallow merge)합니다. 두 번째 인자로 true를 전달하면 전체 교체합니다.
// 얕은 병합 (기본)
set({ name: "이순신" }); // age는 유지됨
// 전체 교체
set({ name: "이순신", age: 40, profile: { bio: "" } }, true);
Action은 Store 정의 안에 함수 형태로 선언합니다. set으로 상태를 변경하고, get으로 현재 상태를 읽습니다.
const useTodoStore = create<TodoState>((set, get) => ({
todos: [] as Todo[],
addTodo: (title: string) => {
const newTodo: Todo = { id: Date.now(), title, done: false };
set((state) => ({ todos: [...state.todos, newTodo] }));
},
toggleTodo: (id: number) => {
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
}));
},
removeDone: () => {
// get()으로 현재 상태를 읽어 활용
const activeTodos = get().todos.filter((todo) => !todo.done);
set({ todos: activeTodos });
},
}));
useStore(selector)처럼 셀렉터 함수를 넘기면, 해당 값이 변경될 때만 컴포넌트가 리렌더링됩니다. Store 전체를 구독하면 관련 없는 상태가 변경될 때도 리렌더링됩니다.
const useStore = create<{ count: number; name: string }>(() => ({
count: 0,
name: "홍길동",
}));
// count가 변경될 때만 리렌더링
function CountDisplay() {
const count = useStore((state) => state.count);
return <p>{count}</p>;
}
// name이 변경될 때만 리렌더링
function NameDisplay() {
const name = useStore((state) => state.name);
return <p>{name}</p>;
}
객체나 배열을 셀렉터로 반환할 때는 참조 동일성 비교를 주의해야 합니다.
import { useShallow } from "zustand/react/shallow";
// 잘못된 예: 매 렌더링마다 새 객체를 반환 → 항상 리렌더링 발생
const { count, name } = useStore((state) => ({ count: state.count, name: state.name }));
// 올바른 예: useShallow로 얕은 비교
const { count, name } = useStore(
useShallow((state) => ({ count: state.count, name: state.name }))
);
Middleware: Store 생성 함수를 감싸 기능을 추가하는 고차 함수, create(middleware(fn)) 형태로 사용
Zustand의 미들웨어는 create의 콜백 함수를 래핑합니다. 공식적으로 devtools, persist, immer 등을 제공합니다.
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
const useStore = create<State>()(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: "counter-storage" }
),
{ name: "CounterStore" }
)
);
미들웨어는 안쪽부터 바깥쪽 순으로 적용됩니다. 위 예시에서는 persist가 먼저 적용되고 devtools가 감쌉니다.
Persist: Store 상태를 localStorage 또는 sessionStorage에 자동으로 저장하고 복원하는 미들웨어
persist 미들웨어를 사용하면 페이지를 새로고침해도 상태가 유지됩니다.
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null as string | null,
user: null as User | null,
setToken: (token: string) => set({ token }),
logout: () => set({ token: null, user: null }),
}),
{
name: "auth-storage", // localStorage 키 이름
storage: createJSONStorage(() => sessionStorage), // 기본값은 localStorage
partialize: (state) => ({ token: state.token }), // 저장할 필드 선택
}
)
);
partialize로 저장할 필드를 제한하면, user처럼 민감하거나 크기가 큰 데이터는 스토리지에서 제외할 수 있습니다.
Devtools: Redux DevTools 확장 프로그램과 연동하여 상태 변화 이력을 시각적으로 추적하는 미들웨어
import { create } from "zustand";
import { devtools } from "zustand/middleware";
const useStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(state) => ({ count: state.count + 1 }),
false,
"counter/increment" // DevTools에 표시될 액션 이름
),
decrement: () =>
set(
(state) => ({ count: state.count - 1 }),
false,
"counter/decrement"
),
}),
{
name: "CounterStore", // DevTools에서 표시될 Store 이름
}
)
);
set의 세 번째 인자로 액션 이름을 지정하면 Redux DevTools에서 어떤 액션이 상태를 변경했는지 추적할 수 있습니다. 브라우저에 Redux DevTools 확장이 설치되어 있어야 합니다.
상태가 많아지면 하나의 create 안에 모두 넣으면 관리가 어려워집니다. 기능별로 slice를 만들고 합치면 파일 분리와 재사용이 쉬워집니다.
// src/store/slices/counterSlice.ts
import type { StateCreator } from "zustand";
export interface CounterSlice {
count: number;
increment: () => void;
}
export const createCounterSlice: StateCreator<CounterSlice> = (set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
});
// src/store/slices/authSlice.ts
import type { StateCreator } from "zustand";
export interface AuthSlice {
token: string | null;
setToken: (token: string) => void;
}
export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
token: null,
setToken: (token) => set({ token }),
});
// src/store/useBoundStore.ts — 슬라이스를 하나로 합침
import { create } from "zustand";
import { createCounterSlice, CounterSlice } from "./slices/counterSlice";
import { createAuthSlice, AuthSlice } from "./slices/authSlice";
type BoundStore = CounterSlice & AuthSlice;
const useBoundStore = create<BoundStore>()((...args) => ({
...createCounterSlice(...args),
...createAuthSlice(...args),
}));
export default useBoundStore;
// 사용 — 합쳐진 Store에서 각 slice의 상태와 액션을 모두 사용 가능
function App() {
const count = useBoundStore((state) => state.count);
const token = useBoundStore((state) => state.token);
return <p>{count} / {token ?? "미인증"}</p>;
}