이번 편의 결과물: store/가 stores/로 정리되고 useUiStore가 완전히 타이핑되어, selector와 액션 호출 모두에서 타입 오류가 자동으로 잡힙니다. · 다루는 개념: create<State>()(...) 패턴, selector 타입 추론, persist 미들웨어와 partialize 타이핑
이 편에서 만드는 파일
bookshelf-ts/src/
├── stores/
│ └── useUiStore.ts + 신규 (store/useUiStore.js를 대체)
├── store/
│ └── useUiStore.js − 삭제
├── components/
│ └── Header.tsx ~ 수정 (import 경로만 stores/로 변경)
└── pages/
└── BookListPage.tsx ~ 수정 (import 경로만 stores/로 변경)개념 정리
지금까지 store/useUiStore.js는 자바스크립트로 남아 있었습니다. 10편에서 DataTable에 정렬 상태(sortBy, setSort)를 연결할 때, 스토어가 아직 타입이 없어 편집기가 그 값들의 정확한 모양을 보여주지 못했던 것을 기억할 것입니다. 이 편에서 스토어 자체를 타이핑하면 이 훅을 쓰는 모든 파일이 한 번에 정확한 타입을 얻습니다. 폴더 이름도 다른 패키지 폴더들과 맞춰 stores/(복수형)로 정리합니다.
create<State>()(...)가 두 번 호출되는 이유
일반 함수라면 create<State>(config)처럼 한 번만 호출해도 될 것 같지만, Zustand는 미들웨어(persist, devtools 등)를 조합할 때 set·get의 타입을 정확히 추론하기 위해 커링(curried call) 형태를 씁니다.
// State만 먼저 확정하고
create<UiState>()
// 그 결과를 실제 스토어 정의(미들웨어 포함)에 적용한다
(persist((set) => ({ ... }), { name: '...' }))create<UiState>()처럼 빈 괄호로 한 번 호출해 타입 매개변수 UiState를 먼저 고정합니다. 그렇게 하지 않고 create<UiState>(persist(...))처럼 한 번에 호출하면, TypeScript가 미들웨어 체인 전체의 타입을 한 번에 추론해야 해서 persist가 감싼 스토어의 타입이 정확히 맞물리지 않는 경우가 생깁니다. 미들웨어를 쓰지 않는 가장 단순한 스토어라면 create<State>((set) => ({...}))처럼 한 번만 호출해도 되지만, persist를 포함한 순간부터는 커링 형태가 표준 패턴입니다.
selector는 따로 타입을 쓰지 않는다
스토어가 create<UiState>()(...)로 만들어지면, useUiStore가 반환하는 값의 타입이 이미 UiState로 확정됩니다. useUiStore((state) => state.statusFilter)라고 쓰면 state의 타입이 자동으로 UiState가 되고, 반환값의 타입도 UiState['statusFilter']로 자동 추론됩니다. selector 함수 매개변수에 타입을 직접 써줄 필요가 없습니다.
persist의 partialize도 타입이 맞아야 한다
partialize 옵션은 저장소에 실제로 남길 부분 상태를 고릅니다. 반환값의 타입은 전체 상태의 부분집합(Partial<UiState>에 해당하는 형태)이어야 하고, 존재하지 않는 필드를 반환하면 오류가 납니다.
실습
1. types/book.ts에서 BookStatus 재사용 확인
09편에서 as const 배열로 다시 정의한 BookStatus를 스토어의 필터 타입에 그대로 씁니다. 새 파일은 만들지 않고 import만 합니다.
// src/types/book.ts (09편 코드 그대로 — 여기서는 import 대상만 확인)
export const BOOK_STATUSES = ['wish', 'reading', 'done'] as const
export type BookStatus = (typeof BOOK_STATUSES)[number]2. useUiStore.ts 작성
// src/stores/useUiStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { BookStatus } from '../types/book.ts'
type SortKey = 'title' | 'author' | 'pages' | 'status'
type SortOrder = 'asc' | 'desc'
type StatusFilter = BookStatus | 'all'
interface UiFilters {
statusFilter: StatusFilter
sortBy: SortKey
sortOrder: SortOrder
searchQuery: string
page: number
}
interface UiState extends UiFilters {
isSidebarOpen: boolean
setStatusFilter: (statusFilter: StatusFilter) => void
setSearchQuery: (searchQuery: string) => void
setPage: (page: number) => void
setSort: (sortBy: SortKey) => void
toggleSidebar: () => void
resetFilters: () => void
}
const initialFilters: UiFilters = {
statusFilter: 'all',
sortBy: 'title',
sortOrder: 'asc',
searchQuery: '',
page: 1,
}
export const useUiStore = create<UiState>()(
persist(
(set) => ({
...initialFilters,
isSidebarOpen: false,
setStatusFilter: (statusFilter) => set({ statusFilter, page: 1 }),
setSearchQuery: (searchQuery) => set({ searchQuery, page: 1 }),
setPage: (page) => set({ page }),
setSort: (sortBy) =>
set((state) => ({
sortBy,
sortOrder: state.sortBy === sortBy && state.sortOrder === 'asc' ? 'desc' : 'asc',
page: 1,
})),
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
resetFilters: () => set(initialFilters),
}),
{
name: 'bookshelf-ui',
partialize: (state) => ({
statusFilter: state.statusFilter,
sortBy: state.sortBy,
sortOrder: state.sortOrder,
}),
},
),
)
export type { UiState, SortKey, SortOrder, StatusFilter }UiFilters를 별도 interface로 분리해 둔 덕분에 initialFilters: UiFilters라고 명시할 수 있고, resetFilters: () => set(initialFilters)가 필터 관련 필드만 정확히 되돌린다는 것이 타입으로 보장됩니다. set 콜백의 매개변수 타입(set, 함수형 업데이터의 state)은 모두 create<UiState>()에서 이미 확정된 UiState를 바탕으로 자동 추론되어 별도 주석이 필요 없습니다. partialize가 반환하는 객체에 UiState에 없는 필드를 넣거나 오타를 내면 그 자리에서 오류가 납니다.
3. 기존 store/useUiStore.js 삭제와 import 경로 정리
rm src/store/useUiStore.js
rmdir src/store이 훅을 가져다 쓰는 두 파일의 import 경로만 고칩니다. 나머지 코드는 그대로 둡니다.
// src/pages/BookListPage.tsx (import 한 줄만 수정 — 나머지는 10편 코드 유지)
import { useUiStore } from '../stores/useUiStore.ts'// src/components/Header.tsx (import 한 줄만 수정 — 나머지는 04편 코드 유지)
import { useUiStore } from '../stores/useUiStore.ts'두 파일 모두 코드 내용은 바뀌지 않았지만, 이제 useUiStore((state) => state.sortBy), useUiStore((state) => state.setSort)처럼 꺼내는 값마다 정확한 타입(SortKey, (sortBy: SortKey) => void)이 자동으로 붙습니다. BookListPage.tsx의 DataTable에 넘기던 setSort도 이제 onSortChange가 요구하는 (key: keyof Book) => void와 정확히 맞는 함수라는 것이 컴파일 타임에 확인됩니다.
4. 실행
npm run dev5. 확인
- 필터·정렬·검색·페이지 이동이 09~10편까지와 동일하게 동작합니다.
- VS Code에서
useUiStore((state) => state.statusFilter)에 마우스를 올리면 반환 타입이StatusFilter(BookStatus | 'all')로 뜨는 것을 확인합니다. setStatusFilter('finished')처럼StatusFilter에 없는 문자열을 일부러 넘겨보고 컴파일 오류가 나는지 확인한 뒤 되돌립니다.partialize가 반환하는 객체에isSidebarOpen: state.isSidebarOpen을 추가해보고, 정상적으로 타입이 통과하는지도 확인해 봅니다(추가해도 되는 필드인지는UiState에 포함되는지로 판단합니다).
직접 해보기
UiState에theme: 'light' | 'dark'와setTheme액션을 추가해 보세요.initialFilters(UiFilters)에는 넣지 않고isSidebarOpen처럼 별도 필드로 둡니다.partialize에서searchQuery도 저장하도록 추가해 보고, 새로고침 후 검색어까지 유지되는지 확인해 보세요.
정답 보기
// src/stores/useUiStore.ts (필드·액션 추가분만 발췌)
interface UiState extends UiFilters {
isSidebarOpen: boolean
theme: 'light' | 'dark'
setTheme: (theme: 'light' | 'dark') => void
// ...기존 액션 유지
}
// create 콜백 안에 추가
theme: 'light',
setTheme: (theme) => set({ theme }),theme은 UiFilters에 넣지 않았으므로 resetFilters(set(initialFilters)) 호출로는 초기화되지 않습니다. 필터와 무관한 화면 설정을 필터 초기화 대상에서 자연스럽게 제외한 것입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
create<UiState>(persist(...))처럼 한 번만 호출해 set 타입이 어긋남 | 커링 호출(create<UiState>()(...))을 빠뜨림 | persist 등 미들웨어를 쓸 때는 빈 괄호를 한 번 더 붙인다 |
selector 콜백에 (state: UiState) => ...처럼 타입을 직접 씀 | create<UiState>()로 이미 확정된 타입을 다시 반복함 | 타입 매개변수를 스토어 생성 시점에만 쓰고, selector에서는 추론에 맡긴다 |
partialize가 반환한 객체 필드에 오타가 있어도 저장은 되는 것처럼 보임 | partialize의 반환 타입을 검사하지 않고 넘어감 | 반환 객체의 각 필드가 UiState에 실제로 존재하는 이름인지 편집기 경고로 확인한다 |
resetFilters가 isSidebarOpen까지 초기화해버림 | initialFilters에 필터 아닌 필드까지 섞어 넣음 | 필터 전용 상태는 UiFilters로, 그 외 UI 상태는 UiState에서만 분리해 선언한다 |