Communitygithub.com

use-zustand

React 프로젝트에서 Zustand 상태 관리를 위한 모범 사례와 규칙을 제공하는 스킬입니다. TanStack Query와의 역할 분리, slices 패턴, 타입 안전성 등 핵심 원칙을 다룹니다.

use-zustand란 무엇인가요?

이 스킬은 React 프로젝트에서 Zustand를 클라이언트 전용 상태 관리 도구로 올바르게 사용하는 방법을 상세히 안내합니다. TanStack Query와의 명확한 역할 분리를 강조하여 서버 상태 데이터를 Zustand에 중복 저장하지 않도록 방지하며, 여러 도메인을 다룰 때 slices 패턴으로 스토어를 구조화하는 방법을 제시합니다. 또한 TypeScript와의 완벽한 통합을 위해 curried `create<State>()(...)` 폼을 사용하고, devtools와 persist 미들웨어를 적절히 활용하는 전략을 설명합니다. 불필요한 리렌더링을 방지하기 위해 `useShallow` 선택자와 구체적인 상태 조각 선택을 권장하며, immer 미들웨어를 통한 불변 업데이트 간소화, 테스트 가능한 스토어 설계까지 포함하는 종합적인 가이드를 제공합니다. Next.js 환경에서는 클라이언트 컴포넌트에서만 Zustand를 사용하고 서버 컴포넌트에서의 사용을 금지하는 등 프레임워크 특화 규칙도 포함되어 있습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add C1131-G/skills

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

use-zustand은(는) 무엇을 하나요?

이 스킬을 Zustand 상태에 직접 사용하세요. use-tanstack-query와 함께 사용하여 클라이언트 상태와 서버 상태를 각각 적절한 주인에게 맡기세요.

또한 범위 내 파일에는 enforce-code-quality와 enforce-typescript-strict를 적용하세요.

React 프로젝트에서 Zustand 스토어를 작성하거나 검토할 때마다 다음 규칙을 적용하세요.

1. Zustand는 클라이언트 전용 상태를 위한 것입니다 — TanStack Query와 역할이 겹치지 않도록 하세요

Zustand는 UI/클라이언트 상태를 담당합니다: 사이드바 열림/닫힘, 다단계 폼 데이터, 테마, 사용자가 활발히 편집 중인 장바구니 내용. 서버 데이터(API에서 가져온 모든 것)는 TanStack Query에 속하며, Zustand 스토어에 중복 저장하지 마세요 — use-tanstack-query를 참조하세요. 두 책임을 혼합하면(예: 가져온 데이터를 "더 쉽게 접근하기 위해" Zustand에 복사) 두 개의 진실 공급원이 생성되어 동기화가 어긋날 수 있습니다.

2. Provider가 필요하지 않지만, 클라이언트 전용입니다

Zustand 스토어는 모듈 수준의 싱글턴입니다 — 컨텍스트 래퍼가 필요 없으며, 훅을 가져와 어디서든 접근할 수 있습니다. 그러나 이는 브라우저 API와 클라이언트별 상태에 의존한다는 의미이기도 합니다. 따라서:

  • Zustand 스토어는 Client Components에서만 초기화/사용하세요 (Next.js에서는 'use client').
  • Server Component에서 Zustand 스토어를 생성하거나 읽지 마세요 — 새로운 QueryClient처럼 "요청별 격리"가 없으므로, 이렇게 하면 사용자/요청 간에 상태가 누출될 위험이 있습니다.

3. 큰 스토어는 하나의 평평한 덩어리 대신 slices 패턴으로 구성하세요

관련 없는 관심사(사용자, 장바구니, UI 테마, 알림 등)가 하나의 객체에 함께 몰려 있는 단일 스토어는 탐색하고 이해하기 어렵습니다. 도메인별로 slices로 분할한 다음 하나의 create() 호출로 구성하세요:

// userSlice.ts
export interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
  logout: () => void;
}

export const createUserSlice: StateCreator<UserSlice> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
});

// cartSlice.ts
export interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
}

export const createCartSlice: StateCreator<CartSlice> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});

// store.ts
type AppState = UserSlice & CartSlice;

export const useAppStore = create<AppState>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
}));

두 도메인이 실제로 독립적이고 서로 상호 작용할 필요가 없는 경우에만 진정한 별도의 스토어(slices가 아님)를 사용하세요(예: 인증 스토어 vs. 분석 전용 스토어). 여러 스토어는 조정 오버헤드를 추가하므로, 기본적으로는 하나의 스토어 내에서 slices를 먼저 사용하세요.

4. 컴포넌트에서 raw set() 호출 대신 명명된 액션 메서드를 사용하세요

액션을 스토어의 명명된 함수로 노출하고, 컴포넌트에서 스토어의 set을 직접 호출하지 마세요.

// 지양: 작동은 하지만 의미적이지 않음 — 컴포넌트가 상태 모양을 알아야 함
const set = useAppStore((state) => state.set);
set({ theme: "dark" });

// 지향: 명명되고, 스스로 문서화하는 액션
const setTheme = useAppStore((state) => state.setTheme);
setTheme("dark");

슬라이스 자체의 액션 내부에서 다른 상태를 읽거나 다른 슬라이스의 액션을 호출하려면 get()을 사용하세요(예: 장바구니도 비우는 logout 액션). 단, 슬라이스 간의 순환 종속성에 주의하세요.

5. 필요한 특정 상태 조각만 선택하세요 — 절대 전체 스토어를 선택하지 마세요

// 지양: 관련 없는 필드라도 모든 스토어 변경 시 리렌더링됨
const state = useAppStore();

// 지향: `user`가 변경될 때만 리렌더링됨
const user = useAppStore((state) => state.user);

6. 여러 값을 선택하거나 객체를 선택할 때는 useShallow를 사용하세요

스토어에서 새로 구성된 객체나 배열(예: { user, theme })을 선택하면 기본 값이 변경되지 않았더라도 모든 렌더링에서 새 참조가 생성되어 불필요한 리렌더링이 발생합니다. useShallow로 감싸서 Zustand가 참조 비교 대신 선택한 값을 얕게 비교하도록 하세요:

import { useShallow } from "zustand/react/shallow";

const { user, theme } = useAppStore(
  useShallow((state) => ({ user: state.user, theme: state.theme }))
);

7. 중첩된 상태 업데이트에는 immer 미들웨어를 사용하세요

여러 수준의 중첩된 상태를 수동으로 펼쳐서 하나의 필드를 업데이트하는 것은 오류가 발생하기 쉽고 장황합니다. immer 미들웨어를 사용하면 실제로는 불변 방식으로 적용되는 직접적인 "변이" 구문을 작성할 수 있습니다:

import { immer } from "zustand/middleware/immer";

const useStore = create<State>()(
  immer((set) => ({
    nested: { deeply: { value: 0 } },
    increment: () =>
      set((state) => {
        state.nested.deeply.value += 1; // 가변처럼 보이지만, 실제로는 불변
      }),
  }))
);

매우 높은 빈도의 업데이트(예: 60fps 애니메이션 상태)에서는 오버헤드가 중요할 수 있으므로 immer를 건너뛰세요; 일반적인 CRUD 스타일 상태에서는 작은 비용 대비 가독성 향상이 충분합니다.

8. curried create<State>()(...) 폼으로 스토어에 타입을 지정하세요

일반 create((set) => ({...}))는 사소한 스토어 이상에서는 상태 제네릭이 불변이기 때문에 TypeScript가 안정적으로 추론할 수 없습니다. 항상 curried 폼을 사용하세요:

interface CounterState {
  count: number;
  increment: () => void;
}

const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

9. 파생/계산된 값을 저장하지 마세요 — 선택자나 컴포넌트에서 계산하세요

값이 이미 스토어에 있는 다른 상태로 계산될 수 있다면(항목 목록에서 합계, 상태 필드에서 불리언) 별도의 필드로 저장하지 마세요 — 업데이트 경로 하나가 다시 계산하는 것을 잊어버리면 동기화가 어긋납니다. 선택자에서 계산하거나, 컴포넌트에서 일반 파생 값으로 계산하세요:

// 지양: 수동으로 동기화를 유지해야 하는 별도의 `itemCount` 필드
addItem: (item) => set((state) => ({ items: [...state.items, item], itemCount: state.itemCount + 1 })),

// 지향: 읽을 때 파생
const itemCount = useCartStore((state) => state.items.length);

10. 세션 간 유지되어야 하는 상태에는 persist를 사용하고, devtools는 개발에서만 사용하세요

  • 페이지 새로고침이나 새 세션에서도 유지되어야 하는 상태(장바구니 내용, 사용자 환경 설정)에는 persist 미들웨어(localStorage/sessionStorage 또는 사용자 정의 저장소 어댑터 사용)를 사용하세요 — 방문할 때마다 초기화되어야 하는 상태에는 사용하지 마세요.
  • persist 옵션에 version을 포함하고, 모양이 변경된 경우 migrate 함수를 포함하여 이전에 유지된 상태가 스키마 변경 후 앱을 자동으로 망가뜨리지 않도록 하세요.
  • devtools 미들웨어를 사용하여 개발 중 상태 변경을 검사하기 위해 Redux DevTools와 통합하세요. 단, 프로덕션 빌드에서는 활성화되지 않도록 하세요.
export const useSettingsStore = create<SettingsState>()(
  devtools(
    persist(
      (set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
      { name: "settings-storage", version: 1 }
    )
  )
);

11. devtools를 사용하여 상태 흐름을 추적하고, React DevTools와 함께 사용하여 불필요한 리렌더링을 잡으세요

devtools는 단순한 있으면 좋은 로거가 아닙니다 — 상태가 변경된 것뿐만 아니라 왜 변경되었는지 디버깅하는 데 적극적으로 사용하세요:

  • 모든 액션에 이름을 지정하여 devtools 타임라인이 모든 업데이트에 대해 익명의 set 항목 대신 의미 있는 자취를 보여주도록 하세요. set에 세 번째 인수로 액션 이름/타입을 전달하세요:

    addItem: (item) =>
      set(
        (state) => ({ items: [...state.items, item] }),
        false,
        "cart/addItem" // Redux DevTools의 액션 목록에 이 레이블로 표시됨
      ),
    
  • 프로젝트에 여러 스토어가 있을 때는 devtools(..., { name: "cart-store" })를 통해 각 스토어에 이름을 지정하여 devtools 패널이 모든 스토어의 업데이트를 모호하게 혼합하여 표시하지 않도록 하세요.

  • 버그가 경쟁 조건이나 업데이트 순서 문제처럼 보일 때는 devtools 타임라인을 사용하여 실제 상태 흐름 순서를 확인하세요 — 액션 목록을 단계별로 살펴보면서 정확히 무엇이, 언제, 이전/다음 상태가 무엇인지 확인하세요. 액션 코드 곳곳에 console.log를 흩뿌리며 추측하지 마세요.

  • 불필요한 리렌더링 진단은 별도의 도구이며, devtools 자체가 보여주는 것이 아닙니다. React DevTools Profiler(또는 호출 사이트 수준의 세부 정보가 필요하면 개발 모드에서 why-did-you-render)를 사용하여 특정 상태 변경 시 어떤 컴포넌트가 리렌더링되었고 그 이유를 확인하세요. 컴포넌트가 선택한 필드와 관련 없는 스토어 업데이트에서 리렌더링된다면, 이는 선택자(규칙 5)나 useShallow 래핑(규칙 6)이 누락되었음을 나타내는 신호입니다 — 스토어가 아닌 선택자를 수정하세요.

12. 스토어를 테스트 가능하게 만드세요: 바인딩된 훅뿐만 아니라 원시 생성자도 내보내세요

훅(컴포넌트용)과 기본 StateCreator/스토어 생성 함수(테스트용)를 모두 내보내면, 테스트가 전체 테스트 스위트에서 모듈 수준 싱글턴을 공유하는 대신 테스트별로 새롭고 격리된 스토어 인스턴스를 생성할 수 있습니다(이렇게 하면 테스트 간 상태 누출이 방지됩니다).

이러한 규칙 적용하기

  • 새로운 스토어: 둘 이상의 도메인이 관련된 경우 처음부터 slices 패턴을 사용하는 단일 스토어로 시작하세요. curried create<State>()(...) 폼으로 타입을 지정하고, 상태가 실제로 필요할 때만 persist/devtools를 추가하세요.
  • 기존 코드 검토: 선택자 함수 없이 전체 스토어를 선택하는 경우, 명명된 액션 대신 컴포넌트에서 raw set() 호출, 별도의 필드로 저장된 파생 상태, TanStack Query 대신 Zustand에 중복 저장된 서버 가져오기 데이터, Server Component에서 접근하는 모든 스토어, devtools 타임라인을 읽기 어렵게 만드는 이름 없는 액션/스토어, 그리고 관련 없는 스토어 변경으로 리렌더링되는 모든 컴포넌트(선택자 또는 useShallow 누락)에 플래그를 지정하세요.

관련 스킬