Zustand scoped store #2. React Context로 지역 store 만들기

Zustand scoped store #2. React Context로 지역 store 만들기

March 13, 2026

1편에서 글로벌 store의 세 가지 한계를 살펴봤습니다.

  • 같은 컴포넌트를 여러 개 렌더링하면 상태가 공유되고
  • 부모에서 받은 props로 store를 초기화할 수 없으며
  • 테스트 간 store 상태가 누수되는 문제였습니다

이번 글에서는 이 문제들을 실제로 해결한 과정을 정리합니다.
React Context와 zustand의 createStore()를 조합해서 컴포넌트 단위로 격리된 store를 만들었습니다.

구현 자체는 어렵지 않았는데, 그 과정에서 create()createStore()의 차이, Context로 store를 공유할 때의 리렌더 동작 같은 개념을 먼저 정리할 필요가 있었습니다.

create()와 createStore(), 왜 두 개가 있을까

zustand를 쓰면서 create()만 써본 분이 많을 겁니다. 저도 그랬습니다. 그런데 scoped store를 만들려면 createStore()라는 다른 함수를 써야 합니다.

처음 참고한 문서는 zustand Next.js 가이드였는데, 여기서 createStore()를 사용하는 걸 보고 create()와 뭐가 다른 건지 궁금해졌습니다. 그래서 이 둘의 차이를 먼저 살펴봤습니다.

// create(): zustand에서 import
// 반환값이 React hook입니다
import { create } from 'zustand';

const useMyStore = create<MyState>((set) => ({ ... }));
// 사용: useMyStore((s) => s.count)
// createStore(): zustand/vanilla에서 import
// 반환값이 plain 객체입니다
import { createStore } from 'zustand/vanilla';

const myStore = createStore<MyState>((set) => ({ ... }));
// 사용: myStore.getState(), useStore(myStore, selector)

두 함수를 나란히 놓고 보면 차이가 보입니다.

create()createStore()
반환 타입UseBoundStore (React hook)StoreApi (plain object)
React 의존OX
글로벌 사용useMyStore(selector) 직접 호출useStore(myStore, selector)
Context 패턴불가적합

여기서 ==Context 패턴에서 create() 불가==라는 부분이 핵심이라고 느꼈습니다.

create()가 반환하는 것은 hook이고, hook을 Context의 value로 전달하면 React의 Rules of Hooks를 위반하게 됩니다.
hook은 컴포넌트 최상위에서만 호출해야 하는데, Context에서 꺼내 쓰는 시점에는 이 규칙이 깨질 수 있거든요.

zustand 메인테이너인 dai-shi도 같은 이야기를 한 적이 있습니다.

“Passing a React hook is considered bad practice because it can allow violating the rule of hooks.”

참고: Zustand GitHub Discussion #1975 - create vs createStore for context

그래서 Context 패턴에서는 createStore()를 써야 한다고 이해했습니다.
createStore()는 React에 의존하지 않는 순수한 객체를 반환하기 때문에 Context value로 전달해도 안전합니다.

이 store 객체를 React에 연결할 때는 zustand에서 제공하는 useStore() hook을 사용합니다.

그런데 한 가지 의문이 생깁니다.
Context에 store를 넣으면, store 값이 바뀔 때마다 Context가 리렌더를 일으키지 않을까요?

Context로 공유하는 건 “store 인스턴스”다

저도 처음에 이게 걱정됐는데, 찾아보니 자주 나오는 의문이었습니다.

TkDodo의 글에서 이 구분을 잘 설명하고 있었습니다.

“The idea is to merely share the store instance via React Context - not the store values themselves.”

참고: TkDodo - Zustand and React Context

이게 왜 중요한지 조금 더 풀어 보면 이렇습니다.

React Context는 value가 바뀔 때 모든 Consumer를 리렌더합니다.
그래서 Context에 자주 바뀌는 값을 넣으면 성능 문제가 생기죠.

하지만 여기서 넣는 건 store 인스턴스, 즉 StoreApi 객체 자체입니다.
이 객체의 참조는 Provider가 마운트된 이후 절대 바뀌지 않습니다.

그렇다면 store 안의 값이 바뀔 때 컴포넌트 리렌더는 누가 일으킬까요?

zustand의 내부 subscription 시스템이 처리합니다.
zustand는 내부적으로 useSyncExternalStore를 사용하여 store 값 변경을 감지하고, selector를 통해 구독한 값이 바뀌었을 때만 해당 컴포넌트를 리렌더합니다.

Info

useSyncExternalStore는 React 18에서 추가된 hook으로, React 외부의 데이터
소스(external store)를 구독할 때 tearing 없이 안전하게 동기화해주는 역할을
합니다.

이 패턴은 사실 새로운 것이 아닙니다. React Query의 QueryClientProviderQueryClient 인스턴스를, Redux의 Provider가 store 인스턴스를 Context로 공유하는 것과 동일한 구조입니다.

정리하면 역할이 이렇게 나뉩니다.

  • Context의 역할: “어떤 store 인스턴스를 사용할지” 컴포넌트 트리에 알려주는 것
  • zustand의 역할: “store 값이 바뀌었을 때 어떤 컴포넌트를 리렌더할지” 결정하는 것

이 구분이 잡히고 나니, 구현 코드가 자연스럽게 읽히기 시작했습니다.

직접 만들어 보기

Step 1. createStore로 팩토리 함수 만들기

store를 생성하는 함수를 먼저 만들었습니다.
이 함수가 호출될 때마다 새로운 store 인스턴스가 만들어지는데, 이것이 글로벌 create()와의 핵심 차이입니다.

import { createStore } from 'zustand/vanilla';

type CounterState = {
  count: number;
  inc: () => void;
  dec: () => void;
};

const createCounterStore = (initialCount = 0) =>
  createStore<CounterState>(set => ({
    count: initialCount,
    inc: () => set(s => ({ count: s.count + 1 })),
    dec: () => set(s => ({ count: s.count - 1 })),
  }));

initialCount 파라미터를 받는 부분을 주목해 주세요.
1편에서 “부모에서 받은 props로 store를 초기화할 수 없다”는 한계를 다뤘는데, 팩토리 함수로 만들면 생성 시점에 원하는 값을 넘길 수 있습니다.
이 복선은 Step 2에서 회수됩니다.

Step 2. React Context와 Provider

다음으로 store 인스턴스를 컴포넌트 트리에 공유하기 위한 Context와 Provider를 만들었습니다.

import { createContext, useContext, useState, type ReactNode } from 'react';
import { useStore, type StoreApi } from 'zustand';

const CounterStoreContext = createContext<StoreApi<CounterState> | null>(null);

function CounterProvider({
  children,
  initialCount = 0,
}: {
  children: ReactNode;
  initialCount?: number;
}) {
  const [store] = useState(() => createCounterStore(initialCount));
  return (
    <CounterStoreContext.Provider value={store}>
      {children}
    </CounterStoreContext.Provider>
  );
}

Tip

여기서 useStatelazy
initializer

쓰고 있습니다. useState에 값 대신 함수를 넘기면, React는 첫 렌더에서만
이 함수를 호출하고 이후 리렌더에서는 무시합니다.

setter를 사용하지 않으므로 store 인스턴스는 Provider가 살아 있는 동안 절대 바뀌지 않습니다.

initialCount props를 받아서 createCounterStore에 넘기는 부분이 앞서 심어둔 복선의 회수입니다.
useEffect 없이, 첫 렌더부터 원하는 초기값으로 store가 동작합니다.

한 가지 궁금할 수 있는 점이 있습니다. useRef로 store를 초기화하는 코드를 본 적이 있으실 겁니다.
zustand/context가 제거된 뒤 커뮤니티에서 논의된 대안 구현에서 이 패턴을 사용합니다.

// 커뮤니티 구현의 useRef 패턴: 동작은 하지만 시맨틱 보장이 약함
const storeRef = useRef<StoreApi<CounterState>>();
if (!storeRef.current) {
  storeRef.current = createCounterStore(initialCount);
}

참고: Zustand GitHub Discussion #1276 - createContext deprecation

이 패턴도 동작합니다. 하지만 TkDodo가 지적했듯, React는 ref 값의 안정성을 시맨틱하게 보장하지 않습니다.

  • Strict Mode에서 컴포넌트를 두 번 마운트할 수 있고
  • 미래의 React(Activity, 구 Offscreen API)에서 ref가 초기화될 가능성도 있습니다

반면 useState의 lazy initializer는 React가 의미론적으로 1회 실행을 보장합니다.
zustand 공식 문서도 Initialize state with props 가이드에서 useState를 사용합니다.

이 차이는 3편에서 유틸리티를 설계할 때 다시 등장합니다.

참고: TkDodo - useState for one-time initializations

Step 3. 커스텀 hook

Provider가 제공하는 store를 편하게 쓸 수 있도록 커스텀 hook을 만들었습니다.

function useCounterStore<T>(selector: (state: CounterState) => T): T {
  const store = useContext(CounterStoreContext);
  if (!store) {
    throw new Error('useCounterStore must be used within <CounterProvider>');
  }
  return useStore(store, selector);
}

세 줄짜리 hook인데, 각 줄이 하는 일을 보면 구조가 드러납니다.

  1. useContext로 가장 가까운 Provider에서 store 인스턴스를 가져옵니다
  2. Provider 없이 사용했을 때 명확한 에러 메시지를 던집니다
  3. useStore가 store 인스턴스와 selector를 연결하여, selector가 반환하는 값이 바뀔 때만 리렌더를 일으킵니다

Step 4. 사용하기

완성된 패턴을 실제로 사용하면 이런 모습입니다.

function CounterDisplay() {
  const count = useCounterStore(s => s.count);
  const inc = useCounterStore(s => s.inc);
  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>+</button>
    </div>
  );
}

// App에서 Provider로 감싸면 끝
function App() {
  return (
    <CounterProvider initialCount={0}>
      <CounterDisplay />
    </CounterProvider>
  );
}

글로벌 create() 패턴과 비교하면, 컴포넌트 코드는 거의 동일합니다.
useCounterStore(selector) 호출 형태도 같고, selector로 필요한 값만 구독하는 것도 같습니다.

달라진 건 store가 Provider 단위로 격리된다는 점뿐입니다.

이 차이가 1편에서 다뤘던 세 가지 한계를 어떻게 해결하는지 확인해 봤습니다.

1편의 세 가지 한계, 해결 확인

다중 인스턴스, Provider를 여러 번 쓰면 된다

<div style={{ display: 'flex', gap: '2rem' }}>
  <CounterProvider>
    <CounterDisplay /> {/* 독립적인 count */}
  </CounterProvider>
  <CounterProvider>
    <CounterDisplay /> {/* 독립적인 count */}
  </CounterProvider>
  <CounterProvider>
    <CounterDisplay /> {/* 독립적인 count */}
  </CounterProvider>
</div>

Provider를 세 번 렌더링하면, useState가 세 번 호출되고, createCounterStore도 세 번 호출됩니다.
각 Provider 안의 CounterDisplay자기만의 store 인스턴스를 바라봅니다.

첫 번째 카운터를 올려도 나머지 둘에는 아무 영향이 없습니다.

1편에서 글로벌 create()로는 이것이 불가능했습니다. 모듈 스코프에 store가 단 하나만 존재했으니까요.

Props 초기화, useEffect 없이 깜빡임 없이

function EditorPage({ initialContent }: { initialContent: string }) {
  return (
    <EditorProvider initialContent={initialContent}>
      <Editor />
    </EditorProvider>
  );
}

Provider의 useState lazy initializer가 initialContent를 캡처하여 store를 생성합니다.
컴포넌트가 마운트되는 순간부터 올바른 초기값으로 동작하기 때문에, useEffect 동기화에서 발생하던 첫 렌더 깜빡임이 사라집니다.

1편에서 이 문제를 “초기화와 동기화는 다르다”고 표현했는데, 이 패턴은 정확히 “초기화”만 수행합니다.

테스트 격리, 매 테스트마다 새 store

function renderWithStore(ui: ReactElement, initialCount = 0) {
  return render(
    <CounterProvider initialCount={initialCount}>{ui}</CounterProvider>
  );
}

it('increments', () => {
  renderWithStore(<Counter />, 0);
  fireEvent.click(screen.getByText('+'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

it('starts from initial', () => {
  renderWithStore(<Counter />, 42);
  expect(screen.getByText('42')).toBeInTheDocument();
  // 이전 테스트의 상태와 완전히 격리됨
});

각 테스트가 renderWithStore를 호출할 때마다 새로운 CounterProvider가 마운트되고, 새로운 store 인스턴스가 생성됩니다.
beforeEach에서 수동으로 state를 초기화할 필요가 없습니다.

테스트가 끝나면 Provider가 언마운트되면서 store도 자연스럽게 사라집니다.

Tip

세 가지 한계가 모두 해결됐습니다. 패턴 자체는 동작합니다.

그런데, store를 하나 더 만들어야 한다면

그런데 문제가 하나 남아 있었습니다. Counter store를 만드는 데 필요했던 코드를 돌아보면 이렇습니다.

  1. createCounterStore 팩토리 함수
  2. CounterStoreContext: createContext 호출
  3. CounterProvider: Context.Provider를 감싸는 컴포넌트
  4. useCounterStore: useContext + useStore를 조합하는 커스텀 hook

프로젝트에 scoped store가 Counter 하나뿐이라면 이 정도는 괜찮습니다.

반복되는 보일러플레이트

Context, Provider, hook 전부 동일한 구조
CounterStore.tsx
type CounterState = {
  count: number
  inc: () => void
}

const Ctx = createContext(…)

const factory = () =>
  createStore<Counter>(…)

export function Provider(
  { children }
) {
  const ref = useRef()
  if (!ref.current)
    ref.current = factory()
  return (
    <Ctx.Provider
      value={ref.current}>
      {children}
    </Ctx.Provider>
  )
}

export function useCounter
Store(sel) {
  return useStore(…, sel)
}
EditorStore.tsx
type EditorState = {
  content: string
  setContent: (s) => void
}

const Ctx = createContext(…)

const factory = () =>
  createStore<Editor>(…)

export function Provider(
  { children }
) {
  const ref = useRef()
  if (!ref.current)
    ref.current = factory()
  return (
    <Ctx.Provider
      value={ref.current}>
      {children}
    </Ctx.Provider>
  )
}

export function useEditor
Store(sel) {
  return useStore(…, sel)
}
FormStore.tsx
type FormState = {
  fields: Record<…>
  setField: (k, v) => void
}

const Ctx = createContext(…)

const factory = () =>
  createStore<Form>(…)

export function Provider(
  { children }
) {
  const ref = useRef()
  if (!ref.current)
    ref.current = factory()
  return (
    <Ctx.Provider
      value={ref.current}>
      {children}
    </Ctx.Provider>
  )
}

export function useForm
Store(sel) {
  return useStore(…, sel)
}

하지만 실제 프로젝트에서는 에디터 store, 폼 store, 대시보드 패널 store…
scoped store가 3개, 5개로 늘어나면 2~4번을 매번 복사해서 타입만 바꿔 붙여넣게 됩니다.

Context 생성, null 체크, 에러 메시지, Provider 컴포넌트. 이 보일러플레이트는 store마다 구조가 동일하고, 달라지는 건 State 타입과 팩토리 함수뿐입니다.

다음 글에서는 이 반복을 약 40줄짜리 유틸리티 하나로 제거한 과정을 다룹니다.
useState vs useRef 초기화 방식의 시맨틱 차이, selector 필수 여부, 에러 메시지 개선 같은 작지만 중요한 설계 결정들이 있었습니다.


레퍼런스