
import { create } from 'zustand'
export const use이름Store = create((set, get) => {
return {
상태: 초깃값,
액션: 함수
}
})
create함수로 스토어를 생성합니다.
create함수의 콜백은set,get매개변수를 가지며, 이를 통해 상태를 변경하거나 조회할 수 있습니다.
create함수의 콜백이 반환하는 객체에서의 속성은 상태(State)이고, 메소드는 액션(Action)이라고 부릅니다.
create함수 호출에서 반환하는 스토어 훅(Hook)은, useCountStore와 같이 use 접두사와 Store 접미사로 명명해 각 컴포넌트에서 사용할 수 있습니다.
// src/store/useCountStore.ts
import { create } from 'zustand';
interface CountState {
count: number;
inc: () => void;
}
export const useCountStore = create<CountState>()((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}));
// App.tsx
const App = () => {
const { count, inc } = useCountStore();
return (
<button onClick={inc}>
Clicked {count} times
</button>
);
};
✅ 사용법이 매우 단순합니다.
모듈별로 “slice”를 쪼개고 합치는 구조. 의존성 최소화 & 타입 분리에 유리합니다. (zustand.docs.pmnd.rs)
// store.ts
import { create } from 'zustand';
export const useStore = create()((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
user: null as null | { name: string },
login: (name: string) => set({ user: { name } }),
}));
| 문제점 |
|---|
| 모든 상태·액션이 한 파일에 뒤섞임 → 커질수록 가독성 ↓ |
타입이 커지면 inc, login 같은 액션이 자동 완성에서 묻힘 |
// slices/counterSlice.ts
export const createCounterSlice = (set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
});
// slices/userSlice.ts
export const createUserSlice = (set) => ({
user: null as null | { name: string },
login: (name: string) => set({ user: { name } }),
});
// store.ts
import { create } from 'zustand';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';
export const useStore = create()((set, get) => ({
...createCounterSlice(set, get),
...createUserSlice(set, get),
}));
이득 : 도메인별 파일 분리 → 도메인별 로직이 분리
// types/counter.ts
export interface CounterSlice {
count: number;
inc: () => void;
}
// slices/counterSlice.ts
import { CounterSlice } from '../types/counter';
export const createCounterSlice = (
set,
): CounterSlice => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
});
이득 :
selectors.ts 도입// selectors/counter.ts
import { useStore } from '../store';
export const useCount = () =>
useStore((s) => s.count); // 필요한 값만 호출
export const useInc = () =>
useStore((s) => s.inc, (a, b) => a === b); // 필요한 함수만 호출
이득 :
import { create } from 'zustand';
import { devtools, persist, immer } from 'zustand/middleware';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';
export const useStore = create(
devtools(
persist(
immer((set, get) => ({
...createCounterSlice(set, get),
...createUserSlice(set, get),
})),
{
name: 'app-storage',
partialize: (s) => ({ // persist 대상만 선택
user: s.user,
}),
},
),
{ name: 'AppStore' },
),
);
이득 :
partialize로 필요 필드만 저장 → 용량·보안 부담 ↓“필요할 때마다 새로운 Zustand 스토어 인스턴스를 만들어 주는 함수”
create()를 직접 호출하는 대신 팩토리 함수를 정의해 두고, 거기서 fresh store를 리턴하도록 하는 패턴입니다.
| 상황 | Store Factory가 해결해 주는 문제 |
|---|---|
| SSR / Next.js | 요청(Request)마다 독립된 스토어가 필요합니다. 싱글턴을 그대로 쓰면 다른 사용자의 데이터가 섞이는 버그·메모리 누수가 발생할 수 있어요. |
| 테스트 | 단위 테스트마다 초기 상태가 다른 스토어를 만들어야 합니다. Factory로 간단히 주입할 수 있습니다. |
| 동적 인스턴스 | 탭·모달처럼 “여러 개가 동시에 떠서 서로 영향을 주면 안 되는” UI를 구현할 때 각 인스턴스마다 스토어를 새로 만들 수 있습니다. |
| 라이브러리/패키지 | NPM 패키지로 배포되는 컴포넌트가 내부적으로 Zustand를 쓰면서, 호스트 앱의 전역 네임스페이스를 오염시키지 않도록 합니다. |
// makeStore.ts
import { createStore } from 'zustand';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';
export type RootState = CounterSlice & UserSlice;
export function makeStore(preloaded?: Partial<RootState>) {
return createStore<RootState>()((set, get) => ({
...createCounterSlice(set, get),
...createUserSlice(set, get),
...preloaded, // 초기 상태 오버라이드
}));
}
// useClientStore.ts
import { useStore as useZustandStore } from 'zustand';
import { makeStore } from './makeStore';
const clientStore = makeStore(); // 탭 당 1개
export const useClientStore = <T,>(sel: (s: RootState) => T) =>
useZustandStore(clientStore, sel);
// pages/index.tsx
export async function getServerSideProps() {
const store = makeStore();
await store.getState().fetchInitialData(); // 서버에서 데이터 채우기
return {
props: { initialZustandState: store.getState() },
};
}
클라이언트에서는 makeStore(props.initialZustandState) 로 하이드레이션.
test('counter increments', () => {
const store = makeStore({ count: 5 });
store.getState().inc();
expect(store.getState().count).toBe(6);
});
| 장점 | 단점 |
|---|---|
| 상태 격리 → 데이터 섞임·메모리 누수 방지 | 코드가 약간 길어짐 (팩토리 + 훅 래퍼 필요) |
| 초기 상태 주입이 쉬움 (테스트·SSR) | DevTools 같은 미들웨어를 훅 래퍼에서 다시 감싸야 함 |
| 동적 인스턴스 지원 | 대부분의 단순 SPA는 굳이 필요 없음 |
그냥
create()한 번 호출해서 내보내는 싱글턴 스토어면 됩니다.
Store Factory는 “격리가 꼭 필요한 시나리오”에서만 도입하세요.
- Store Factory = 스토어 인스턴스를 만들어 주는 함수
- SSR, 테스트, 동적 UI, 패키지 개발처럼 상태 격리가 필요한 상황에서 유용
- 평범한 CSR 앱이라면 싱글턴 스토어로도 충분—필요할 때만 쓰자!
// makeStore.ts
import { StateCreator, StoreApi, createStore } from 'zustand';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';
export type RootState = CounterSlice & UserSlice;
export const makeStore = (preloaded?: Partial<RootState>): StoreApi<RootState> =>
createStore<RootState>((set, get) => ({
...createCounterSlice(set, get),
...createUserSlice(set, get),
...preloaded,
}));
// store.ts (CSR 전용 훅)
import { useStore as useZustandStore } from 'zustand';
import { makeStore } from './makeStore';
const store = makeStore();
export const useStore = <T,>(selector: (s: RootState) => T) =>
useZustandStore(store, selector);
const testStore = makeStore({ count: 42 }) → 단위 테스트에서 원하는 초기 상태 주입 getServerSideProps 마다 makeStore() 호출 → 요청 간 데이터 격리src/
├─ store/
│ ├─ makeStore.ts # SSR·테스트 겸용
│ ├─ store.ts # CSR 훅
│ └─ slices/
│ ├─ counterSlice.ts
│ └─ userSlice.ts
├─ selectors/
│ ├─ counter.ts
│ └─ user.ts
└─ types/
├─ counter.ts
└─ user.ts
| 개선 포인트 | 결과 |
|---|---|
| 파일·타입·액션·셀렉터 분리 | 가독성 ↑, 충돌 ↓ |
| 미들웨어 통합 래핑 | Slice 수정 없이 기능 추가/제거 |
| Store Factory 도입 | 테스트·SSR·MFE 재사용성 ↑ |
| 부분 persist | 보안·성능 균형 |
서버 상태(server state)는 React Query, UI·글로벌 상태(client state)는 Zustand에 맡기는 것이 정석입니다. 둘을 자연스럽게 엮는 3가지 패턴을 살펴봅니다.
// slices/filterSlice.ts
export interface FilterSlice {
keyword: string;
setKeyword: (k: string) => void;
}
export const createFilterSlice = (set): FilterSlice => ({
keyword: '',
setKeyword: (k) => set({ keyword: k }),
});
// Posts.tsx
import { useQuery } from '@tanstack/react-query';
import { useStore } from '@/store';
const fetchPosts = async (keyword: string) => {
const res = await fetch(`/api/posts?search=${keyword}`);
return res.json();
};
export default function Posts() {
const keyword = useStore((s) => s.keyword);
const { data, isLoading } = useQuery(['posts', keyword], () => fetchPosts(keyword));
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
queryClient.invalidateQueries() 호출import { queryClient } from '@/lib/queryClient';
export const createTodoSlice = (set) => ({
addTodo: async (title: string) => {
await fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) });
queryClient.invalidateQueries(['todos']); // ✅ 리스트 새로고침
},
});
useQuery 결과를 Zustand에 동기화 (옵션)서버 데이터를 전역에서 읽기 전용으로 여러 컴포넌트가 소비해야 할 때만 권장.
const { data } = useQuery(['settings'], fetchSettings, {
onSuccess: (settings) => useStore.setState({ settings }),
});
| 패턴 | 언제 쓰나 | 장점 |
|---|---|---|
| 쿼리 키에 Slice 값 포함 | 검색·필터·페이지네이션 | 구현 단순, 자동 refetch |
| invalidateQueries() | POST/PUT/DELETE 이후 목록 동기화 | 컴포넌트 수정 없이 최신화 |
| onSuccess → setState | 설정·권한처럼 다수 컴포넌트가 필요 | 중복 fetch 방지, 전역 접근 |
핵심은 역할 분리: 서버 동기화는 React Query, 앱 로컬 상태는 Zustand가 맡고, 교집합은 쿼리 키 or 캐시 무효화로 느슨하게 연결합니다.
src/
├─ store/
│ ├─ makeStore.ts # SSR·테스트 겸용 스토어 팩토리
│ ├─ useClientStore.ts # CSR 전용 훅 (싱글턴)
│ └─ slices/
│ ├─ counterSlice.ts
│ ├─ userSlice.ts
│ └─ filterSlice.ts
├─ selectors/ # (선택) 공통 셀렉터 모음
│ ├─ counter.ts
│ └─ user.ts
├─ types/ # Slice 인터페이스 정의
│ ├─ counter.ts
│ └─ user.ts
├─ lib/
│ └─ queryClient.ts # React‑Query 전역 클라이언트
├─ pages/ or components/ … # UI 컴포넌트
| 파일/폴더 | 역할 |
|---|---|
| store/makeStore.ts | 요청·테스트마다 fresh store를 만드는 팩토리 함수 |
| store/useClientStore.ts | 브라우저 탭 당 1개의 싱글턴 스토어 + 얇은 훅 래퍼 |
| store/slices/ | 도메인별 순수 Slice 함수들 (비즈니스 로직만) |
| selectors/ | (선택) 재사용 셀렉터 훅으로 리렌더 최적화 |
| types/ | Slice 인터페이스를 모아 타입 의존성 분리 |
| lib/queryClient.ts | React Query QueryClient 싱글턴 & 설정 |
useClientStore.ts에서 DevTools·Persist 등 통합.makeStore, 클라이언트 로직은 useClientStore로 분리.이 구조를 베이스로 팀·프로젝트 규모에 맞춰 선택적으로 selectors/, types/ 폴더를 생략하거나 추가하면 됩니다.