Zustand scoped store #3. 유틸리티, 직접 만들어 쓰기

Zustand scoped store #3. 유틸리티, 직접 만들어 쓰기

March 13, 2026

2편에서 createStore() + React Context 조합으로 세 가지 한계를 해결했습니다.
다중 인스턴스, props 초기화, 테스트 격리 모두 동작합니다.

그런데 프로젝트에 scoped store가 하나둘 늘어나면서 불편함이 생겼습니다.
store를 하나 추가할 때마다 Context 생성, Provider 컴포넌트 작성, custom hook 정의를 반복하고 있었습니다.

구조는 동일한데 타입과 이름만 다릅니다.

WITHOUT NAME

커뮤니티 구현: name 옵션 없음
CONSOLE
✕ Error: Seems like you have
  not used zustand provider
  as an ancestor.
COMPONENTS
▾ <Anonymous>
  ▾ <Anonymous>
    <Component />

에러 메시지와 DevTools 모두 어떤 store의 Provider가 누락되었는지 특정할 수
없음

WITH NAME

제 구현: name 옵션 추가
CONSOLE
✕ Error: useStore must be
  used within
  <Counter.Provider>
COMPONENTS
▾ <Counter.Provider>
  ▾ <Todo.Provider>
    ▾ <Editor.Provider>
      <Component />

name 옵션 하나로 에러 메시지와 DevTools 모두에서 store를 특정할 수 있음

{
  /* CounterStore를 위한 Context, Provider, hook */
}
const CounterContext = createContext<StoreApi<CounterState> | null>(null);

function CounterProvider({ children, createStore }) {
  const [store] = useState(createStore);
  return <CounterContext value={store}>{children}</CounterContext>;
}

function useCounterStore<T>(selector: (s: CounterState) => T) {
  const store = useContext(CounterContext);
  if (!store) throw new Error('Missing CounterProvider');
  return useStore(store, selector);
}

{
  /* TodoStore를 위한 Context, Provider, hook: 위와 거의 동일 */
}
const TodoContext = createContext<StoreApi<TodoState> | null>(null);
{
  /* ... 같은 패턴 반복 */
}

store가 3~4개만 돼도 같은 코드를 복붙하고 있는 자신을 발견합니다.

이 보일러플레이트를 한 번에 제거하는 유틸리티를 만들기로 했습니다.
그런데 바로 구현에 뛰어들기보다, 먼저 커뮤니티가 이 문제를 어떻게 풀었는지를 살펴봤습니다.

참고한 세 가지 레퍼런스

유틸리티 설계에 앞서 세 가지 레퍼런스를 정리했습니다.
각각에서 가져올 것과 바꿀 것이 달랐고, 그 판단이 최종 구현의 방향을 잡아줬습니다.

zustand 공식 docs

공식 문서는 “Initialize state with props” 가이드에서 createStore() + Context 패턴을 보여줍니다.
2편에서 따랐던 그 패턴입니다.

구조적으로 올바르지만, 매번 반복해야 한다는 점은 공식 문서도 다루지 않았습니다.
패턴의 “정답”은 여기서 확인하되, 보일러플레이트 제거는 별도로 풀어야 할 숙제였습니다.

참고: Zustand 공식 docs - Initialize state with props

GitHub Discussion #1276

v5에서 zustand/context가 제거된 뒤, 커뮤니티가 Discussion에서 대안을 논의했습니다. 여러 구현이 오갔지만 결국 하나의 패턴으로 수렴했습니다.

참고: Zustand GitHub Discussion #1276 - createContext deprecation

핵심은 [Provider, useStore] 튜플을 반환하는 팩토리 함수였습니다.
React의 useState[value, setter]를, useReducer[state, dispatch]를 반환하는 것과 같은 관례입니다.

React 생태계에서 이미 익숙한 형태라서, 별도 학습 비용 없이 바로 이해할 수 있었습니다.

Provider 쪽에서는 createStore prop을 받아 컴포넌트 내부에서 store를 생성합니다.
렌더 시점의 props를 클로저로 캡처할 수 있어, 1편에서 다뤘던 “props 초기화” 문제가 자연스럽게 해결되는 구조입니다.

그런데 커뮤니티 구현의 소스를 열어보면, store 초기화에 useRef를 사용하고 있었습니다.

// Discussion에서 수렴한 구현 패턴
const Provider = ({ createStore, ...rest }) => {
  const storeRef = useRef<Store>();
  return createElement(StoreContext.Provider, {
    value: (storeRef.current ||= createStore()),
    ...rest,
  });
};

storeRef.current ||= createStore()는 “ref가 비어 있으면 store를 생성한다”는 뜻입니다.
직관적이고 간결합니다.

하지만 이 패턴에 대한 의문이 하나 있었고, 그 답을 세 번째 레퍼런스에서 찾았습니다.

TkDodo 블로그, useState가 더 안전한 이유

TkDodo는 zustand와 React Context를 다루는 블로그 포스트에서, store 인스턴스의 초기화 방법에 대한 핵심 통찰을 제시합니다.

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

참고: TkDodo - Zustand and React Context

Context로 공유하는 것은 store의 이 아니라 store 인스턴스입니다.
값이 바뀌어도 Context가 리렌더를 일으키지 않고, 값 변경 구독은 zustand의 내부 subscription이 처리합니다.

이 구분이 중요한 이유는, store 인스턴스는 컴포넌트 생명주기 동안 정확히 한 번만 생성되어야 하기 때문입니다.

그렇다면 “한 번만 생성”을 어떻게 보장할 것인가. 여기서 TkDodo의 별도 포스트가 결정적이었습니다.

참고: TkDodo - useState for one-time initializations

TkDodo는 useStatelazy initializer가 React의 시맨틱 계약에 의해 1회 실행이 보장된다고 설명합니다.
반면 useRefuseMemo에는 그런 보장이 없습니다.

  • React 문서 자체가 *“You may rely on useMemo as a performance optimization, not as a semantic guarantee”*라고 명시하고 있고, useRef도 마찬가지입니다.
  • Strict Mode에서 컴포넌트를 두 번 마운트할 수 있습니다.
  • 미래의 React(Activity, 구 Offscreen API)에서 ref가 초기화될 가능성도 열려 있습니다.

이 세 레퍼런스를 교차해서 보니, 무엇을 가져오고 무엇을 바꿔야 하는지가 윤곽이 잡혔습니다.

설계 결정, 무엇을 가져오고 무엇을 바꿨나

유틸리티를 만들기 전에, 각 결정 사항을 정리해 봤습니다. 한 눈에 보면 이렇습니다.

결정 사항근거
튜플 반환 [Provider, useStore]React 생태계 컨벤션 (useState, useReducer)
Provider에 createStore prop렌더 시점에 store 생성, props 접근 가능
useState(createStore)React가 보장하는 1회 초기화
useStoreWithEqualityFnequalityFn 지원으로 기존 zustand 패턴 완전 호환
selector 필수불필요한 리렌더 방지, 의존성 명시
name 옵션에러 메시지 + DevTools displayName
useStoreApi 미제공selector를 통한 선언적 접근만 허용

테이블만으로는 판단의 맥락이 전달되지 않으니, 각 결정의 “왜”를 풀어보겠습니다.

useState vs useRef, 코드 스타일이 아니라 계약의 차이

앞서 커뮤니티 구현이 useRef + ||= 패턴을 사용한다고 했습니다.
동작은 합니다.

그런데 여기서 물음이 생겼습니다.
“동작한다”와 “보장된다”는 같은 걸까요.

useRef는 React가 ref 값의 안정성을 보장하지 않습니다.
현재의 React에서는 실질적으로 안전하게 동작하지만, React 팀이 약속한 것은 아닙니다.

TkDodo가 명쾌하게 정리한 것처럼, useState의 lazy initializer는 React의 시맨틱 계약에 기반한 1회 실행입니다.

// 커뮤니티 구현: useRef
const storeRef = useRef<Store>();
// storeRef.current ||= createStore()
// "동작한다", 하지만 React의 보장 대상이 아님

// 제가 택한 구현: useState
const [store] = useState(createStore);
// React가 시맨틱으로 보장하는 1회 초기화
// setter를 쓰지 않으므로 값이 절대 변하지 않음

store 인스턴스가 두 번 생성되면 구독이 어긋나고, 디버깅하기 극도로 어려운 버그가 됩니다.
**“아마 괜찮을 것”보다 “React가 보장하는 것”**에 기대는 쪽을 택했습니다.

FLOW A · useStore

zustand/react
상태 변경
selector 실행
Object.is()
참조 비교
false
새 객체 참조
리렌더 발생
불필요한 업데이트
useStore(store, s => ({ count: s.count }))
// 매번 새 객체를 반환 → Object.is는 항상 false → 리렌더

selector가 새 객체를 반환하면, 값이 같아도 참조가 다르므로 매번 리렌더됨

FLOW B · useStoreWithEqualityFn

zustand/traditional
상태 변경
selector 실행
equalityFn()
shallow / custom
true
값이 같음
리렌더 스킵
성능 최적화
useStoreWithEqualityFn(store, selector, shallow)
// shallow 비교로 값이 같으면 리렌더를 건너뜀

equalityFn으로 이전 값과 현재 값을 비교 → 같으면 컴포넌트를 업데이트하지
않음

useStoreWithEqualityFn을 zustand/traditional에서 가져온 이유

zustand/react의 표준 useStoreObject.is로만 리렌더 여부를 판단합니다.
객체를 반환하는 selector를 쓰면, 내부 값이 같더라도 참조가 다르므로 매번 리렌더가 발생합니다.

// zustand/react의 useStore: equalityFn 없음
useStore(store, s => ({ count: s.count, name: s.name }));
// 매 상태 변경마다 새 객체 → Object.is는 항상 false → 항상 리렌더

zustand/traditionaluseStoreWithEqualityFn은 내부적으로 useSyncExternalStoreWithSelector를 사용합니다.
useSyncExternalStore의 확장 버전으로, selector와 equalityFn을 추가로 받아 커스텀 비교 로직을 적용할 수 있게 해줍니다.

// useStoreWithEqualityFn 내부의 핵심
useSyncExternalStoreWithSelector(
  api.subscribe,
  api.getState,
  api.getInitialState,
  selector,
  equalityFn // 이 인자가 핵심
);

이 덕분에 useShallow나 커스텀 비교 함수와 조합할 수 있습니다.

글로벌 store에서 useShallow를 쓰던 개발자가 scoped store로 전환했을 때, 같은 패턴이 그대로 동작해야 합니다.

useStoreWithEqualityFn을 쓰지 않으면 이 호환성이 깨집니다.

Note

zustand/traditional
use-sync-external-store
패키지를 peer dependency로 요구합니다. zustand v5에서 이 패키지가 peer
dependency로 분리된 이유가 바로 이것입니다.

selector를 필수로 만든 이유

커뮤니티 구현에서는 selector를 생략할 수 있습니다. selector 없이 호출하면 전체 state를 반환합니다.

// 커뮤니티 구현: selector 생략 가능
const state = useStore();
// 전체 state 구독 → store의 어떤 필드가 바뀌어도 리렌더

편리해 보이지만, 전체 state 구독은 거의 항상 실수입니다.
컴포넌트가 count만 필요한데 name이 바뀌어도 리렌더가 발생합니다.

프로젝트 초기에는 문제없어 보이다가, state가 커지면서 성능 이슈로 돌아옵니다.

그래서 저는 selector를 필수로 강제하도록 했습니다.

// 제 구현: selector 없이 호출하면 TypeScript 에러
const count = useStore(s => s.count); // OK
const state = useStore(); // TS Error

selector를 강제하면 컴포넌트가 “어떤 상태에 의존하는지”를 명시적으로 선언하게 됩니다.
코드 리뷰에서 불필요한 구독을 발견하기 쉬워지고, 리렌더 최적화가 기본값이 됩니다.

Tip

약간의 타이핑을 더 요구하는 대신, 구조적으로 더 안전한 쪽을 택한 것입니다.

name 옵션으로 에러 메시지가 달라진다

커뮤니티 구현의 에러 메시지는 범용적입니다.

"Seems like you have not used zustand provider as an ancestor."

scoped store가 하나일 때는 이것으로 충분합니다.

하지만 store가 여러 개인 프로젝트에서는 어떤 Provider가 빠져 있는지 알 수 없습니다.
저는 name 옵션을 추가해서 에러 메시지에 Provider 이름을 포함시켰습니다.

"useStore must be used within <Counter.Provider>"

같은 원리로, React DevTools에서도 차이가 납니다.

Provider.displayName = `${name}.Provider`;
// DevTools: <Counter.Provider> vs <Anonymous>

컴포넌트 트리가 복잡한 프로젝트에서 DevTools로 디버깅할 때, Provider가 <Anonymous>로 표시되면 어떤 store의 Provider인지 구분할 수 없습니다.

이 작은 추가가 디버깅 경험에서 꽤 큰 차이를 만들었습니다.

useStoreApi를 의도적으로 배제한 이유

2편에서 글로벌 store는 컴포넌트 외부에서 getState()로 명령형 접근이 가능하다고 했습니다. scoped store에서도 useStoreApi()를 제공하면 비슷한 패턴이 가능합니다.

// 이런 API를 의도적으로 제공하지 않았습니다
const storeApi = useStoreApi();
storeApi.getState().inc(); // selector 없는 명령형 접근

이를 배제한 이유는 selector 필수 정책과 직결됩니다.

useStoreApi가 있으면 어디서든 getState()로 전체 state에 접근할 수 있고, 이는 selector를 통한 의존성 명시라는 설계 의도를 무력화합니다.

이벤트 핸들러에서 최신 state가 필요하다면, action을 selector로 가져와서 호출하는 것이 더 명시적입니다.

// 권장: action을 selector로 구독
const inc = useStore(s => s.inc);
const handleClick = () => inc();

// 제공하지 않는 패턴: storeApi로 직접 접근
const api = useStoreApi();
const handleClick = () => api.getState().inc();

기능을 빼는 것은 부족해서가 아니라, 일관된 사용 패턴을 유지하기 위한 선택이었습니다.

React 19 호환, Context.Provider 대신 Context 직접 사용

React 19부터 <Context.Provider value={}> 대신 <Context value={}>로 직접 렌더링할 수 있습니다.
React 팀은 향후 Context.Provider를 deprecated할 예정이라고 밝혔습니다.

“In future versions we will deprecate <Context.Provider>.”

참고: React 19 Release Notes

커뮤니티 구현은 여전히 createElement(StoreContext.Provider, ...)를 사용합니다.
저는 React 19 문법을 적용했습니다.

// 커뮤니티 구현 (React 18 호환)
createElement(StoreContext.Provider, { value: store, ...rest });

// 제 구현 (React 19)
<StoreContext value={store}>{children}</StoreContext>;

이 변경으로 JSX가 더 직관적이 됩니다.
미래의 deprecation 경고도 미리 피할 수 있습니다.

최종 구현 (전체 ~40줄)

여섯 가지 설계 결정을 모두 반영한 최종 코드입니다.

import { createContext, useContext, useState, type ReactNode } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import type { StoreApi } from 'zustand';

type CreateZustandContextOptions = {
  name?: string;
};

export function createZustandContext<TState>(
  options?: CreateZustandContextOptions
) {
  const StoreContext = createContext<StoreApi<TState> | undefined>(undefined);
  const name = options?.name ?? 'ZustandContext';

  function Provider({
    createStore,
    children,
  }: {
    createStore: () => StoreApi<TState>;
    children: ReactNode;
  }) {
    const [store] = useState(createStore);
    return <StoreContext value={store}>{children}</StoreContext>;
  }
  Provider.displayName = `${name}.Provider`;

  function useStore<TSlice = TState>(
    selector: (state: TState) => TSlice,
    equalityFn?: (a: TSlice, b: TSlice) => boolean
  ): TSlice {
    const store = useContext(StoreContext);
    if (store === undefined) {
      throw new Error(`useStore must be used within <${name}.Provider>`);
    }
    return useStoreWithEqualityFn(store, selector, equalityFn);
  }

  return [Provider, useStore] as const;
}

커뮤니티 구현과 나란히 비교하면 차이가 명확해집니다.

커뮤니티 구현createZustandContext
Store 초기화useRef + ||=useState (TkDodo 패턴)
Provider 렌더링createElement(Context.Provider)<Context value={}> (React 19)
제네릭 타입<State, Store> (Store 커스터마이즈 가능)<TState> (단순)
selector기본값 있음 (생략 가능)필수
에러 메시지범용 메시지<${name}.Provider> 포함
displayName없음${name}.Provider

40줄이 채 안 되지만, 각 줄에 앞서 고민했던 설계 판단이 녹아 있다고 생각합니다.

핵심 시나리오 검증

유틸리티가 실제로 기대한 대로 동작하는지, 두 가지 시나리오로 확인했습니다.

useShallow와 함께 사용

2편에서 “useShallow 같은 기존 zustand 패턴과 호환되어야 한다”는 요구사항을 언급했습니다. useStoreWithEqualityFn을 채택한 결정이 여기서 효과를 발휘합니다.

const [TodoProvider, useTodoStore] = createZustandContext<TodoState>({
  name: 'Todo',
});

function TodoList() {
  const { todos, filter } = useTodoStore(
    useShallow(s => ({ todos: s.todos, filter: s.filter }))
  );
  // useShallow가 내부적으로 shallow equality를 equalityFn으로 전달
  // useStoreWithEqualityFn이 이를 받아 처리
  // → todos나 filter가 실제로 변했을 때만 리렌더
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

만약 표준 useStore(zustand/react)를 사용했다면, useShallow가 전달하는 equalityFn을 받을 곳이 없습니다.
매번 리렌더가 발생했을 것입니다.

devtools 미들웨어와 함께

createStore prop에 전달하는 팩토리 함수 내부에서는 zustand의 미들웨어를 자유롭게 체이닝할 수 있습니다. 글로벌 create()에서 쓰던 미들웨어 패턴이 그대로 동작합니다.

const [CounterProvider, useCounterStore] = createZustandContext<CounterState>({
  name: 'Counter',
});

const createDevtoolsStore = (initialCount = 0) =>
  createStore<CounterState>()(
    devtools(
      set => ({
        count: initialCount,
        inc: () => set(s => ({ count: s.count + 1 }), false, 'inc'),
        dec: () => set(s => ({ count: s.count - 1 }), false, 'dec'),
      }),
      { name: 'Counter' }
    )
  );

{
  /* 사용 */
}
<CounterProvider createStore={() => createDevtoolsStore(10)}>
  <CounterDisplay />
</CounterProvider>;

devtools, persist, immer 등 어떤 미들웨어든 createStore 내부에서 조합하면 됩니다.
유틸리티는 store의 내부 구조에 관여하지 않고, StoreApi 인터페이스만 요구합니다.

주의사항과 한계

이 유틸리티가 모든 상황을 커버하지는 않았습니다.
사용하면서 알게 된 제약들을 정리해 봅니다.

Provider 마운트 후 store 교체 불가

useState로 초기화하므로, Provider가 마운트된 뒤에는 store 인스턴스를 교체할 수 없습니다.
이것은 의도한 동작입니다.

“초기화”는 한 번만 일어나야 하고, 이후 상태 변경은 store의 setState를 통해야 합니다.

만약 props 변경에 따라 store를 완전히 재생성해야 한다면, key prop으로 Provider를 리마운트하면 됩니다.

<CounterProvider key={itemId} createStore={() => createCounterStore(itemId)}>
  {children}
</CounterProvider>

key가 바뀌면 React가 기존 컴포넌트를 언마운트하고 새로 마운트하므로, useState의 초기화가 다시 실행됩니다.

중첩 Provider

같은 Context를 중첩하면 React의 기본 동작대로 가장 가까운 Provider의 store를 사용합니다.

<CounterProvider createStore={() => createCounterStore(0)}>
  {/* useCounterStore → count: 0의 store */}
  <CounterProvider createStore={() => createCounterStore(100)}>
    {/* useCounterStore → count: 100의 store */}
  </CounterProvider>
</CounterProvider>

Warning

이 동작을 의도적으로 활용하는 경우도 있지만, 실수로 중첩하면 예상치 못한
동작이 발생할 수 있습니다.

글로벌 store가 더 적합한 경우

1편에서 다뤘지만 다시 짧게 정리해 봅니다.
판단 기준은 상태의 생명주기입니다.

앱 전체와 같은 생명주기를 가져야 하는 상태라면 여전히 글로벌 create()가 맞습니다.

  • 앱과 생명주기를 함께하는 상태. 인증, 테마, 로케일처럼 앱이 시작할 때 생겨나고, 앱이 종료될 때 사라지는 상태입니다.
  • 컴포넌트 트리 외부에서 접근해야 하는 상태. Axios interceptor 등에서 접근이 필요하다면, React 생명주기 밖에서도 살아있어야 합니다.
  • React 생명주기와 무관하게 유지되어야 하는 상태. WebSocket 연결이나 백그라운드 동기화처럼, 컴포넌트의 마운트/언마운트와 무관한 상태입니다.

반대로, 특정 페이지나 컴포넌트와 함께 생겨나고 사라져야 하는 상태라면 scoped store가 적합합니다.
scoped store는 글로벌의 “대체”가 아니라 “보완”이라는 점은, 시리즈 전체를 관통하는 전제라고 생각합니다.

시리즈를 돌아보며

1편에서 글로벌 store의 세 가지 한계를 확인했고, 2편에서 createStore() + React Context로 해결했으며, 이 글에서 반복되는 보일러플레이트를 ~40줄 유틸리티로 제거했습니다.

시리즈를 진행하면서 세 가지 생각이 남았습니다.

  • 검증된 커뮤니티 패턴의 인터페이스를 따르되, 프로젝트의 철학에 맞게 커스터마이징하는 것이 가장 실용적이라고 느꼈습니다. Discussion에서 수렴한 [Provider, useStore] 인터페이스는 그대로 가져왔지만, 초기화 방식(useState), selector 정책(필수), 에러 메시지(name 옵션)는 프로젝트에 맞게 바꿨습니다.

  • “무엇을 제공하지 않을지”가 API 설계의 핵심이었습니다. useStoreApi를 배제한 것은 기능이 부족해서가 아니라, selector를 통한 선언적 접근만 허용하겠다는 설계 철학입니다. 기능을 추가하는 것보다 빼는 결정이 더 오래 고민한 부분이었습니다.

  • 레퍼런스가 여러 개일 때, 각각에서 “왜” 그렇게 했는지를 이해하면 자연스럽게 최선의 조합이 보였습니다. 공식 docs에서 패턴의 정답을 확인하고, 커뮤니티 Discussion에서 인터페이스를 가져오고, TkDodo 블로그에서 초기화의 안전성을 배웠습니다. “왜”를 이해하지 않고 코드만 가져왔다면, useRefuseState의 차이를 모른 채 동작하는 코드를 쓰고 있었을 것입니다.


레퍼런스