Advanced TypeScript Guide

김동현·2026년 3월 4일

zustand 공식문서 번역

목록 보기
12/19

안녕하세요! 오늘 Zustand의 고급 타입스크립트 가이드(Advanced TypeScript Guide) 학습을 함께할 강사입니다.

문서가 영어로 되어있고, 타입스크립트의 깊은 원리를 다루고 있어서 처음 보면 "이게 대체 무슨 소리지?" 싶을 수 있어요. 하지만 천천히 뜯어보면 다 피가 되고 살이 되는 내용들입니다. 특히 포트폴리오 프로젝트에 적용하시거나 기술 면접을 보실 때, "왜 Zustand는 이런 패턴을 쓰나요?"라는 질문에 멋지게 대답할 수 있는 핵심 지식들이 담겨있죠.

원문 내용을 하나도 빠짐없이 번역하면서, 중간중간 제가 이해하기 쉽게 부연 설명과 실무 팁을 더해드릴게요. 자, 시작해볼까요!


고급 타입스크립트 가이드 (Advanced TypeScript Guide)

기본 사용법 (Basic usage)

타입스크립트를 사용할 때의 가장 큰 차이점은 create(...) 대신 create<T>()(...) 라고 작성해야 한다는 점이에요.

타입 매개변수인 <T>와 함께 추가적인 괄호 ()가 들어간다는 점을 꼭 눈여겨보세요. 여기서 T는 상태(state)의 타입을 명시하기 위해 사용됩니다. 예를 들면 다음과 같아요:

import { create } from 'zustand'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

👨‍🏫 강사의 부연 설명 & 팁:
처음 Zustand를 쓰면 이 create<State>()(...) 형태가 정말 생소하게 느껴집니다. 자바스크립트에선 그냥 create((set) => ({})) 였으니까요. 왜 굳이 빈 괄호 ()를 한 번 더 호출하는 '커링(Currying)' 패턴을 썼을까요? 이 부분은 기술 면접에서도 단골로 나오는 주제입니다. 아래 공식 문서의 설명에서 그 이유를 파헤쳐 봅시다!

💡 왜 초기 상태에서 타입을 바로 추론할 수 없을까요?

세 줄 요약 (TLDR): 상태 제네릭 T가 '무변성(invariant)'을 띠기 때문입니다.

이 최소화된 버전의 create 함수를 생각해보세요:

declare const create: <T>(f: (get: () => T) => T) => T

const x = create((get) => ({
  foo: 0,
  bar: () => get(),
}))
// `x`는 다음 인터페이스 대신 `unknown`으로 추론됩니다.
// interface X {
//   foo: number,
//   bar: () => X
// }

여기서 create 안에 있는 f의 타입을 보면, 즉 (get: () => T) => T에서, 이 함수는 반환(return)을 통해 T를 "제공"하기도 하고(이를 공변성, covariant라 합니다), get을 통해 T를 "받기"도 합니다(이를 반공변성, contravariant라 합니다).

타입스크립트는 여기서 혼란에 빠집니다. "그럼 T는 대체 어디서 오는 거지?" 마치 닭이 먼저냐 달걀이 먼저냐 하는 문제와 같죠. 결국 타입스크립트는 포기하고 Tunknown으로 추론해버립니다.

따라서, 추론해야 할 제네릭이 무변성(invariant, 즉 공변성과 반공변성을 동시에 가짐)을 띠는 한, 타입스크립트는 이를 제대로 추론할 수 없습니다. 또 다른 간단한 예시는 다음과 같습니다:

const createFoo = {} as <T>(f: (t: T) => T) => T
const x = createFoo((_) => 'hello')

여기서도 xstring이 아니라 unknown이 됩니다.

👨‍🏫 강사의 부연 설명 & 팁:
"공변성? 반공변성? 무변성?" 용어가 너무 어렵죠? 쉽게 말해 타입스크립트는 어떤 타입을 추론할 때, '입력으로 들어오는 타입''출력으로 나가는 타입'이 서로를 참조하며 얽혀있으면 뇌정지가 온다고 생각하시면 됩니다. Zustand의 상태는 값을 읽어오기도(get()) 하고 변경된 상태를 반환하기도 하기 때문에 타입스크립트가 스스로 타입을 유추하지 못하는 한계가 있는 것이죠.

💡 추론에 대한 추가 설명 (타입스크립트에 관심 있는 분들을 위한 심화 내용)

어떤 의미에서 이런 추론 실패는 진짜 '문제'가 아닙니다. 왜냐하면 <T>(f: (t: T) => T) => T 타입의 값은 실제로 작성할 수가 없기 때문이에요. 즉, createFoo의 실제 런타임 구현을 작성하는 것 자체가 불가능하다는 뜻입니다. 한번 시도해 볼까요?

const createFoo = (f) => f(/* ? */)

createFoof의 반환값을 반환해야 합니다. 그러려면 먼저 f를 호출해야겠죠. f를 호출하려면 T 타입의 값을 전달해야 합니다. T 타입의 값을 전달하려면 먼저 그 값을 만들어내야 하고요.

그런데 T가 뭔지도 모르는데 어떻게 T 타입의 값을 만들어낼 수 있을까요? T 타입의 값을 만들어낼 유일한 방법은 f를 호출하는 것뿐인데, f를 호출하려면 다시 T 타입의 값이 필요합니다. 무한 반복이죠! 그래서 createFoo를 실제로 작성하는 건 불가능합니다.

우리가 말하고자 하는 바는, createFoo의 경우 구현 자체가 불가능하므로 추론 실패가 큰 문제가 아니라는 점입니다. 그렇다면 create의 경우는 어떨까요? 사실 create 역시 완벽하게 구현하는 것은 불가능합니다. 잠깐, create를 구현하는 게 불가능하다면 Zustand는 대체 어떻게 이걸 구현한 걸까요? 정답은, '완벽하게 구현하지 않았다' 입니다.

Zustand는 create의 타입을 완벽하게 구현한 척 거짓말을 하고 있습니다. 사실은 대부분의 부분만 구현했을 뿐이죠. 타입의 불건전성(unsoundness)을 보여주는 간단한 증거가 있습니다. 다음 코드를 보세요:

import { create } from 'zustand'

const useBoundStore = create<{ foo: number }>()((_, get) => ({
  foo: get().foo,
}))

이 코드는 타입 에러 없이 컴파일됩니다. 하지만 실제로 실행해보면 다음과 같은 예외가 발생합니다: "Uncaught TypeError: Cannot read properties of undefined (reading 'foo')".

이유는 초기 상태가 생성되기 전에는 getundefined를 반환하기 때문입니다(따라서 초기 상태를 생성할 때는 get을 호출하면 안 됩니다). 타입스크립트의 타입 정의는 get이 절대 undefined를 반환하지 않을 것이라고 약속하지만, 실제로는 초기에 undefined를 반환하므로 Zustand가 이를 완벽히 구현하지 못했다는 뜻입니다.

물론 타입이 약속하는 대로 create를 구현하는 것은 (마치 createFoo를 구현하는 것과 마찬가지로) 불가능하기 때문에 Zustand도 어쩔 수 없었던 것입니다. 즉, 우리가 실제로 구현한 create를 완벽하게 표현할 수 있는 타입이 존재하지 않습니다.

그렇다고 get의 타입을 () => T | undefined로 지정하면 코드를 작성할 때마다 매번 undefined 체크를 해야 해서 너무 불편해집니다. 게다가 get은 결국 나중에는 확실히 () => T가 맞기 때문에 이 역시 정확한 타입은 아닙니다. 단지 동기적으로(synchronously) 바로 호출했을 때만 () => undefined가 될 뿐이죠. 우리에게 필요한 건 get(() => T) & WhenSync<() => undefined> 처럼 타이핑할 수 있는 기상천외한 타입스크립트 기능인데, 당연히 이런 기능은 존재하지 않습니다.

정리하자면 우리에겐 두 가지 문제가 있습니다: 추론 부족(lack of inference)불건전성(unsoundness)입니다.
추론 부족은 타입스크립트가 무변성(invariants)에 대한 추론을 개선한다면 해결될 수 있습니다. 불건전성은 타입스크립트가 WhenSync 같은 기능을 도입한다면 해결되겠죠.

지금 당장 추론 부족 문제를 우회하기 위해 우리는 상태 타입을 수동으로 지정(annotate)합니다. 불건전성 문제는 우회할 방법이 없지만, 큰 문제는 아닙니다. 어차피 상태를 정의하는 도중에 get을 동기적으로 호출하는 것 자체가 말이 안 되니까요.

💡 왜 커링(Currying) ()(...) 형태를 쓰나요?

세 줄 요약 (TLDR): microsoft/TypeScript#10571 (타입스크립트 이슈)를 해결하기 위한 우회책(workaround)입니다.

이런 상황을 상상해 보세요:

declare const withError: <T, E>(
  p: Promise<T>,
) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
declare const doSomething: () => Promise<string>

const main = async () => {
  let [error, value] = await withError(doSomething())
}

여기서 Tstring으로 잘 추론되지만, Eunknown으로 추론됩니다. 여러분은 doSomething()이 던질 에러의 형태를 확실히 알고 있기 때문에 E의 타입을 Foo라고 명시하고 싶을 수 있습니다.

하지만 타입스크립트에서는 그게 불가능합니다. 제네릭은 전부 다 명시하거나, 아예 하나도 명시하지 않거나 둘 중 하나만 선택해야 합니다. EFoo로 명시하려면, 어차피 자동으로 추론되는 T까지 굳이 string이라고 함께 적어줘야 하죠.

이 문제를 해결하는 방법은 런타임에는 아무 일도 하지 않는 커링된(curried) 버전withError를 만드는 것입니다. 이 녀석의 유일한 목적은 여러분이 E 타입만 명시할 수 있도록 허용해주는 것입니다.

declare const withError: {
  <E>(): <T>(
    p: Promise<T>,
  ) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
  <T, E>(
    p: Promise<T>,
  ): Promise<[error: undefined, value: T] | [error: E, value: undefined]>
}
declare const doSomething: () => Promise<string>
interface Foo {
  bar: string
}

const main = async () => {
  let [error, value] = await withError<Foo>()(doSomething())
}

이렇게 하면 T는 여전히 자동으로 추론되고, 여러분은 E 타입만 쏙 골라서 지정할 수 있게 됩니다. Zustand 역시 상태(첫 번째 타입 매개변수)는 명시적으로 지정하고 싶지만, 나머지 매개변수들은 타입스크립트가 알아서 추론하게 내버려두고 싶기 때문에 똑같은 방식을 사용하는 것입니다.

👨‍🏫 강사의 부연 설명 & 팁:
이 부분 진짜 중요합니다! 프론트엔드 개발자로 협업할 때 커스텀 훅이나 유틸리티 함수를 만들다 보면 "일부 타입만 내가 정해주고, 나머지는 인자를 보고 알아서 추론하게 할 순 없나?"라는 고민에 빠집니다. 그럴 때 Zustand가 사용한 이 '더미 함수 커링 패턴 ()()'을 적용해보세요. 시니어 개발자들이 코드를 보고 "오, 이 친구 타입스크립트 꽤 치는데?" 라고 생각할 겁니다.


대안으로, combine 미들웨어를 사용할 수도 있습니다. 이 녀석은 상태를 스스로 추론해 주기 때문에 굳이 타입을 명시할 필요가 없습니다.

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useBearStore = create(
  combine({ bears: 0 }, (set) => ({
    increase: (by: number) => set((state) => ({ bears: state.bears + by })),
  })),
)

💡 조금 주의할 점 (Be a little careful)

combine을 쓸 때 우리는 매개변수로 받는 set, get, store의 타입에 대해 약간의 '거짓말'을 함으로써 타입 추론을 성공시킵니다.

그 거짓말이란, 마치 상태(state)가 '첫 번째 매개변수'인 것처럼 타이핑되어 있다는 점입니다. 사실 실제 상태는 첫 번째 매개변수(초기 상태 객체)와 두 번째 매개변수(함수)가 반환하는 객체의 얕은 병합(shallow-merge: { ...a, ...b }) 결과물인데 말이죠.

예를 들어, 두 번째 매개변수 안의 get은 타입이 () => { bears: number }로 되어 있습니다. 하지만 사실 이건 거짓말이고 실제로는 () => { bears: number, increase: (by: number) => void }가 맞습니다. 다행히 스토어 자체인 useBearStore는 올바른 타입을 가집니다. 예를 들어 useBearStore.getState의 타입은 () => { bears: number, increase: (by: number) => void }로 정확하게 나옵니다.

사실 이게 완전한 거짓말은 아닌 게, { bears: number }는 여전히 { bears: number, increase: (by: number) => void }의 서브타입(하위 타입)이기 때문입니다. 따라서 대부분의 경우에는 아무 문제가 없습니다.

다만 replace 플래그를 사용할 때는 조심하셔야 합니다. 예를 들어, set({ bears: 0 }, true) 라고 작성하면 타입 에러 없이 컴파일은 되지만, replace 특성상 상태를 아예 덮어씌워버리기 때문에 기존에 있던 increase 함수가 통째로 삭제되는 불건전한(unsound) 상태가 됩니다.

또 주의해야 할 상황은 Object.keys를 사용할 때입니다. Object.keys(get())을 호출하면 타입 상으로는 ["bears"]가 나올 것 같지만, 실제 런타임에서는 ["bears", "increase"]가 반환됩니다. get의 반환 타입만 믿고 코딩하다간 이런 함정에 빠질 수 있습니다.

요약하자면, combine은 상태 타입을 일일이 적지 않아도 되는 편리함을 얻는 대신, 약간의 타입 안전성(type-safety)을 포기하는 방식입니다. 따라서 상황에 맞게 combine을 잘 활용하시길 바랍니다. 대부분의 경우엔 문제가 없으므로 아주 편리하게 쓰실 수 있습니다.

👨‍🏫 강사의 부연 설명 & 팁:
Zustand의 set 함수는 기본적으로 기존 상태에 새로운 상태를 덮어쓰는(shallow merge) 방식으로 동작해요. 그래서 굳이 ...state를 하지 않아도 변경할 값만 넘겨주면 되죠.
하지만 두 번째 인자로 true를 주면 덮어쓰기가 아니라 아예 객체를 새로 교체(replace)해버립니다. combine을 썼을 때 이 기능을 남발하면 메서드들이 다 날아갈 수 있으니 주의하세요!


참고로 combine을 사용할 때는 커링된 버전(create()())을 사용하지 않습니다. 왜냐하면 combine 자체가 상태를 "생성(create)"해주기 때문입니다. 상태를 생성하는 미들웨어를 쓸 때는 이제 상태 타입이 자동으로 추론될 수 있으므로 커링된 버전이 필요 없습니다.

상태를 생성하는 또 다른 미들웨어로는 redux가 있습니다. 따라서 combine, redux 혹은 상태를 생성하는 커스텀 미들웨어를 사용할 때는 커링 버전을 사용하지 않는 것을 권장합니다.

만약 상태 선언부 외부에서도 상태 타입을 추론해서 쓰고 싶다면, ExtractState 타입 헬퍼를 사용하실 수 있습니다:

import { create, ExtractState } from 'zustand'
import { combine } from 'zustand/middleware'

type BearState = ExtractState<typeof useBearStore>

const useBearStore = create(
  combine({ bears: 0 }, (set) => ({
    increase: (by: number) => set((state) => ({ bears: state.bears + by })),
  })),
)

미들웨어 사용하기 (Using middlewares)

타입스크립트에서 미들웨어를 사용하기 위해 특별히 뭔가 더 해야 할 일은 없습니다.

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()(
  devtools(
    persist(
      (set) => ({
        bears: 0,
        increase: (by) => set((state) => ({ bears: state.bears + by })),
      }),
      { name: 'bearStore' },
    ),
  ),
)

다만 문맥적 추론(contextual inference)이 제대로 작동하려면 미들웨어들을 반드시 create 내부에서 즉시 사용해야 한다는 점만 기억하세요. 다음 예시의 myMiddlewares처럼 조금이라도 미들웨어를 밖으로 빼서 재사용하려 한다면 훨씬 복잡한 고급 타입 지정이 필요해집니다.

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

// 이렇게 밖으로 빼면 타입 추론이 끊깁니다!
const myMiddlewares = (f) => devtools(persist(f, { name: 'bearStore' }))

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()(
  myMiddlewares((set) => ({
    bears: 0,
    increase: (by) => set((state) => ({ bears: state.bears + by })),
  })),
)

또한, devtools 미들웨어는 가능한 가장 바깥쪽(마지막)에 감싸서 사용하는 것을 강력히 권장합니다.

예를 들어 immer를 미들웨어로 같이 쓴다면 immer(devtools(...))가 아니라 devtools(immer(...)) 형태로 작성해야 합니다. 왜냐하면 devtools는 내부적으로 setState 함수를 변형(mutate)하여 거기에 타입 매개변수를 추가하는데, 만약 immer 같은 다른 미들웨어가 devtools보다 먼저 setState를 변형해버리면 그 타입 정보가 유실될 수 있기 때문입니다. 따라서 devtools를 가장 마지막에 적용해야 다른 미들웨어들이 그 이전에 setState를 변형하지 않도록 보장할 수 있습니다.

👨‍🏫 강사의 부연 설명 & 팁:
면접이나 실무에서 Zustand 세팅할 때 devtools 위치 잘못 잡아서 로그가 제대로 안 찍히거나 타입 에러 나는 경우가 은근히 많습니다. "devtools는 항상 맨 바깥쪽 껍질이다!" 라고 외워두시면 에러를 방지할 수 있어요.


미들웨어 제작 및 고급 사용법 (Authoring middlewares and advanced usage)

이런 가상의 미들웨어를 직접 작성해야 한다고 상상해 봅시다.

import { create } from 'zustand'

const foo = (f, bar) => (set, get, store) => {
  store.foo = bar
  return f(set, get, store)
}

const useBearStore = create(foo(() => ({ bears: 0 }), 'hello'))
console.log(useBearStore.foo.toUpperCase())

Zustand 미들웨어는 스토어 객체 자체를 변형(mutate)할 수 있습니다. 그렇다면 이렇게 스토어에 속성을 추가하는 변형을 타입 레벨에서는 도대체 어떻게 표현할 수 있을까요? 즉, 위 코드가 컴파일되도록 foo의 타입을 어떻게 지정해야 할까요?

일반적인 정적 타입 언어에서는 이게 불가능합니다. 하지만 다행히도 타입스크립트 덕분에, Zustand는 이 문제를 해결하기 위한 "고차 변형자(higher-kinded mutator)"라는 것을 가지고 있습니다. 미들웨어를 직접 타이핑하거나 StateCreator 타입을 사용하는 등 복잡한 타입 문제를 다루고 있다면, 이 구현 디테일을 반드시 이해해야 합니다. 자세한 내용은 #710 이슈를 확인해 보세요.

만약 이 특정 문제에 대한 정답 코드가 너무 궁금하시다면, 이 문서 아래쪽의 스토어 타입을 변경하는 미들웨어 섹션을 보시면 됩니다.

동적 replace 플래그 다루기 (Handling Dynamic replace Flag)

만약 replace 플래그의 값이 컴파일 타임에 정해져 있지 않고 런타임에 동적으로 결정된다면, 타입 에러를 마주할 수 있습니다. 이를 해결하기 위해 setState 함수의 매개변수 타입을 추출하여 replace 파라미터에 명시하는(as Parameters) 우회책을 사용할 수 있습니다:

const replaceFlag = Math.random() > 0.5
const args = [{ bears: 5 }, replaceFlag] as Parameters<
  typeof useBearStore.setState
>
store.setState(...args)

as Parameters 우회책 예시

import { create } from 'zustand'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

const replaceFlag = Math.random() > 0.5
const args = [{ bears: 5 }, replaceFlag] as Parameters<
  typeof useBearStore.setState
>
useBearStore.setState(...args) // 우회책 사용

이 방식을 따르면, 동적인 replace 플래그를 넘길 때도 타입 이슈 없이 안전하게 코드를 작성할 수 있습니다.


자주 쓰이는 레시피 (Common recipes)

스토어 타입을 변경하지 않는 미들웨어 (Middleware that doesn't change the store type)

import { create, StateCreator, StoreMutatorIdentifier } from 'zustand'

type Logger = <
  T,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
  f: StateCreator<T, Mps, Mcs>,
  name?: string,
) => StateCreator<T, Mps, Mcs>

type LoggerImpl = <T>(
  f: StateCreator<T, [], []>,
  name?: string,
) => StateCreator<T, [], []>

const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
  const loggedSet: typeof set = (...a) => {
    set(...(a as Parameters<typeof set>))
    console.log(...(name ? [`${name}:`] : []), get())
  }
  const setState = store.setState
  store.setState = (...a) => {
    setState(...(a as Parameters<typeof setState>))
    console.log(...(name ? [`${name}:`] : []), store.getState())
  }

  return f(loggedSet, get, store)
}

export const logger = loggerImpl as unknown as Logger

// ---

const useBearStore = create<BearState>()(
  logger(
    (set) => ({
      bears: 0,
      increase: (by) => set((state) => ({ bears: state.bears + by })),
    }),
    'bear-store',
  ),
)

스토어 타입을 변경하는 미들웨어 (Middleware that changes the store type)

import {
  create,
  StateCreator,
  StoreMutatorIdentifier,
  Mutate,
  StoreApi,
} from 'zustand'

type Foo = <
  T,
  A,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
  f: StateCreator<T, [...Mps, ['foo', A]], Mcs>,
  bar: A,
) => StateCreator<T, Mps, [['foo', A], ...Mcs]>

declare module 'zustand' {
  interface StoreMutators<S, A> {
    foo: Write<Cast<S, object>, { foo: A }>
  }
}

type FooImpl = <T, A>(
  f: StateCreator<T, [], []>,
  bar: A,
) => StateCreator<T, [], []>

const fooImpl: FooImpl = (f, bar) => (set, get, _store) => {
  type T = ReturnType<typeof f>
  type A = typeof bar

  const store = _store as Mutate<StoreApi<T>, [['foo', A]]>
  store.foo = bar
  return f(set, get, _store)
}

export const foo = fooImpl as unknown as Foo

type Write<T extends object, U extends object> = Omit<T, keyof U> & U

type Cast<T, U> = T extends U ? T : U

// ---

const useBearStore = create(foo(() => ({ bears: 0 }), 'hello'))
console.log(useBearStore.foo.toUpperCase())

커링 우회책 없이 create 사용하기 (create without curried workaround)

create를 사용하는 가장 권장되는 방법은 앞서 설명한 커링 우회책을 사용하는 것입니다 (create<T>()(...) 형태). 이 방식이 스토어 타입을 가장 완벽하게 추론하게 해주기 때문이죠.

하지만 어떤 이유로든 이 우회책을 쓰기 싫다면, 아래처럼 타입 매개변수들을 억지로 다 넣어줄 수 있습니다. 하지만 주의하세요! 이 방식은 종종 타입 단언(assertion)처럼 작동해버릴 수 있어서 저희는 권장하지 않습니다.

import { create } from "zustand"

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<
  BearState,
  [
    ['zustand/persist', BearState],
    ['zustand/devtools', never]
  ]
>(devtools(persist((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}), { name: 'bearStore' })))

슬라이스 패턴 (Slices pattern)

import { create, StateCreator } from 'zustand'

interface BearSlice {
  bears: number
  addBear: () => void
  eatFish: () => void
}

interface FishSlice {
  fishes: number
  addFish: () => void
}

interface SharedSlice {
  addBoth: () => void
  getBoth: () => number
}

const createBearSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  BearSlice
> = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})

const createFishSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  FishSlice
> = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})

const createSharedSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  SharedSlice
> = (set, get) => ({
  addBoth: () => {
    // 이전 메서드들을 재사용할 수 있습니다.
    get().addBear()
    get().addFish()
    // 아니면 아예 처음부터 새로 작성해도 됩니다.
    // set((state) => ({ bears: state.bears + 1, fishes: state.fishes + 1 })
  },
  getBoth: () => get().bears + get().fishes,
})

const useBoundStore = create<BearSlice & FishSlice & SharedSlice>()((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
  ...createSharedSlice(...a),
}))

슬라이스 패턴에 대한 더 자세한 설명은 여기(slices-pattern)에서 확인하실 수 있습니다.

👨‍🏫 강사의 부연 설명 & 팁:
포트폴리오를 만들거나 실무에서 큰 규모의 앱을 만들 때 정말 중요한 게 바로 이 '슬라이스 패턴(Slices Pattern)'입니다! 상태가 커지면 한 파일에 모든 코드를 때려 넣기보다는 이렇게 기능별로 쪼개고 나중에 useBoundStore에서 하나로 합쳐주는 방식이 훨씬 유지보수하기 좋습니다. StateCreator에 제네릭 4개를 넘기는 부분이 복잡해 보이지만, StateCreator<전체상태, 뮤테이터, 훅, 현재슬라이스> 라고 이해하시면 편합니다.

만약 미들웨어를 함께 사용하고 있다면 StateCreator<MyState, [], [], MySlice>StateCreator<MyState, Mutators, [], MySlice>로 변경해야 합니다.
예를 들어 devtools를 사용 중이라면 StateCreator<MyState, [["zustand/devtools", never]], [], MySlice>가 됩니다. 모든 뮤테이터(mutators) 목록은 아래의 "미들웨어와 뮤테이터 레퍼런스" 섹션을 참고하세요.

Vanilla 스토어를 위한 바운딩된 useStore 훅 (Bounded useStore hook for vanilla stores)

import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const bearStore = createStore<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

function useBearStore(): BearState
function useBearStore<T>(selector: (state: BearState) => T): T
function useBearStore<T>(selector?: (state: BearState) => T) {
  return useStore(bearStore, selector!)
}

만약 바운딩된 useStore 훅을 자주 만들어야 해서 코드를 재사용(DRY)하고 싶다면, 추상화된 createBoundedUseStore 함수를 만들어 쓸 수도 있습니다...

import { useStore, StoreApi } from 'zustand'
import { createStore } from 'zustand/vanilla'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const bearStore = createStore<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

const createBoundedUseStore = ((store) => (selector) =>
  useStore(store, selector)) as <S extends StoreApi<unknown>>(
  store: S,
) => {
  (): ExtractState<S>
  <T>(selector: (state: ExtractState<S>) => T): T
}

type ExtractState<S> = S extends { getState: () => infer X } ? X : never

const useBearStore = createBoundedUseStore(bearStore)

미들웨어와 뮤테이터 레퍼런스 (Middlewares and their mutators reference)

  • devtools["zustand/devtools", never]
  • persist["zustand/persist", YourPersistedState]
    YourPersistedState는 여러분이 영속화(persist)할 상태의 타입입니다. 즉, options.partialize의 반환 타입이죠. 만약 partialize 옵션을 넘기지 않는다면 YourPersistedStatePartial<YourState>가 됩니다. 또한 가끔씩 실제 PersistedState를 넘기는 게 작동하지 않을 때가 있습니다. 그럴 때는 unknown을 넘겨보세요.
  • immer["zustand/immer", never]
  • subscribeWithSelector["zustand/subscribeWithSelector", never]
  • redux["zustand/redux", YourAction]
  • combinecombine은 스토어를 변형하지 않으므로 뮤테이터가 없습니다.

이 페이지 수정하기 (Edit this page)

이전 (Previous)
초급 타입스크립트 가이드 (Beginner TypeScript Guide)

다음 (Next)
선택자 자동 생성하기 (Auto Generating Selectors)


수고하셨습니다! 타입스크립트로 Zustand를 다루는 심화 과정이 끝났습니다. 더 궁금한 점이나, 실제 프로젝트(블로그 뷰어 등!)에 적용하면서 막히는 부분이 있다면 언제든 질문 남겨주세요! 제가 도와드릴게요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글