zustand의 presist가 어떤 작업을 하는지 이제 한줄한줄 알아보자
presist는 크게 4개로 나뉘어져 있다
1. 타입정의
2. 스토리지 생성
3. 핵심 로직
4. 유틸리티 헬퍼 함수
export interface StateStorage {
getItem: (name: string) => string | null | Promise<string | null>
setItem: (name: string, value: string) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
Copyexport type StorageValue<S> = {
state: S
version?: number
}
export interface PersistStorage<S> {
getItem: (name: string) => StorageValue<S> | null | Promise<StorageValue<S> | null>
setItem: (name: string, value: StorageValue<S>) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
type JsonStorageOptions = {
reviver?: (key: string, value: unknown) => unknown
replacer?: (key: string, value: unknown) => unknown
}
export interface PersistOptions<S, PersistedState = S> {
name: string
storage?: PersistStorage<PersistedState> | undefined
partialize?: (state: S) => PersistedState
onRehydrateStorage?: (
state: S,
) => ((state?: S, error?: unknown) => void) | void
version?: number
migrate?: (
persistedState: unknown,
version: number,
) => PersistedState | Promise<PersistedState>
merge?: (persistedState: unknown, currentState: S) => S
skipHydration?: boolean
}
type PersistListener<S> = (state: S) => void
type StorePersist<S, Ps> = {
persist: {
setOptions: (options: Partial<PersistOptions<S, Ps>>) => void
clearStorage: () => void
rehydrate: () => Promise<void> | void
hasHydrated: () => boolean
onHydrate: (fn: PersistListener<S>) => () => void
onFinishHydration: (fn: PersistListener<S>) => () => void
getOptions: () => Partial<PersistOptions<S, Ps>>
}
}
type Thenable<Value> = {
then<V>(
onFulfilled: (value: Value) => V | Promise<V> | Thenable<V>,
): Thenable<V>
catch<V>(
onRejected: (reason: Error) => V | Promise<V> | Thenable<V>,
): Thenable<V>
}
export interface StateStorage {
getItem: (name: string) => string | null | Promise<string | null>
setItem: (name: string, value: string) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
unknown | Promise<unknown> 동기와 비동기를 둘다 받을 수 있게 넣어서 동일한 방식으로 데이터를 사용할수 있다.Copyexport type StorageValue<S> = {
state: S
version?: number
}
export interface PersistStorage<S> {
getItem: (name: string) => StorageValue<S> | null | Promise<StorageValue<S> | null>
setItem: (name: string, value: StorageValue<S>) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
value 값으로 string 만 받는다면, PersistStorage는 value 값으로 StorageValue<S> 타입을 받는다.export interface PersistOptions<S, PersistedState = S> {
name: string // 스토리지에서 사용할 고유한 이름
storage?: PersistStorage<PersistedState> | undefined // 커스텀 스토리지
partialize?: (state: S) => PersistedState // 저장할 상태를 필터링
onRehydrateStorage?: (state: S) => ((state?: S, error?: unknown) => void) | void // 상태 복원 콜백
version?: number // 상태의 버전
migrate?: (persistedState: unknown, version: number) => PersistedState | Promise<PersistedState> // 버전 마이그레이션
merge?: (persistedState: unknown, currentState: S) => S // 상태 병합 방식
skipHydration?: boolean // 초기 hydration 스킵 여부
}
이를 통해 상태의 저장, 복원, 버전 관리, 마이그레이션 등 다양한 측면을 세밀하게 제어할 수 있다.
type JsonStorageOptions = {
reviver?: (key: string, value: unknown) => unknown
replacer?: (key: string, value: unknown) => unknown
}
createJSONStorage 에서 사용export function createJSONStorage<S>(
getStorage: () => StateStorage,
options?: JsonStorageOptions,
): PersistStorage<S> | undefined {
let storage: StateStorage | undefined
try {
storage = getStorage()
} catch {
// prevent error if the storage is not defined (e.g. when server side rendering a page)
return
}
const persistStorage: PersistStorage<S> = {
getItem: (name) => {
const parse = (str: string | null) => {
if (str === null) {
return null
}
return JSON.parse(str, options?.reviver) as StorageValue<S>
}
const str = (storage as StateStorage).getItem(name) ?? null
if (str instanceof Promise) {
return str.then(parse)
}
return parse(str)
},
setItem: (name, newValue) =>
(storage as StateStorage).setItem(
name,
JSON.stringify(newValue, options?.replacer),
),
removeItem: (name) => (storage as StateStorage).removeItem(name),
}
return persistStorage
}
이 함수는 local이나 session 같은 기본 웹 스토리지(StateStorage)를 생성하고, 이를 JSON 형식의 객체 형태의 데이터를 저장할 수 있는 저장소(PersistStorage)로 변환해주는 함수이다.
// Promise와 유사한 인터페이스 정의
type Thenable<Value> = {
then<V>(
onFulfilled: (value: Value) => V | Promise<V> | Thenable<V>,
): Thenable<V>
catch<V>(
onRejected: (reason: Error) => V | Promise<V> | Thenable<V>,
): Thenable<V>
}
// 일반 값이나 Promise를 Thenable로 변환하는 함수
const toThenable =
<Result, Input>(
fn: (input: Input) => Result | Promise<Result> | Thenable<Result>,
) =>
(input: Input): Thenable<Result> => {
try {
const result = fn(input)
// 이미 Promise면 그대로 반환
if (result instanceof Promise) {
return result as Thenable<Result>
}
// 일반 값이면 Thenable 객체로 변환
return {
then(onFulfilled) {
return toThenable(onFulfilled)(result as Result)
},
catch(_onRejected) {
return this as Thenable<any>
},
}
} catch (e: any) {
// 에러 발생시 catch로 처리할 수 있는 Thenable 반환
return {
then(_onFulfilled) {
return this as Thenable<any>
},
catch(onRejected) {
return toThenable(onRejected)(e)
},
}
}
}
const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
type S = ReturnType<typeof config>
let options = {
storage: createJSONStorage<S>(() => localStorage),
partialize: (state: S) => state,
version: 0,
merge: (persistedState: unknown, currentState: S) => ({
...currentState,
...(persistedState as object),
}),
...baseOptions,
}
let hasHydrated = false
const hydrationListeners = new Set<PersistListener<S>>()
const finishHydrationListeners = new Set<PersistListener<S>>()
let storage = options.storage
if (!storage) {
return config(
(...args) => {
console.warn(
`[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`,
)
set(...(args as Parameters<typeof set>))
},
get,
api,
)
}
const setItem = () => {
const state = options.partialize({ ...get() })
return (storage as PersistStorage<S>).setItem(options.name, {
state,
version: options.version,
})
}
const savedSetState = api.setState
api.setState = (state, replace) => {
savedSetState(state, replace as any)
void setItem()
}
const configResult = config(
(...args) => {
set(...(args as Parameters<typeof set>))
void setItem()
},
get,
api,
)
api.getInitialState = () => configResult
// a workaround to solve the issue of not storing rehydrated state in sync storage
// the set(state) value would be later overridden with initial state by create()
// to avoid this, we merge the state from localStorage into the initial state.
let stateFromStorage: S | undefined
// rehydrate initial state with existing stored state
const hydrate = () => {
if (!storage) return
// On the first invocation of 'hydrate', state will not yet be defined (this is
// true for both the 'asynchronous' and 'synchronous' case). Pass 'configResult'
// as a backup to 'get()' so listeners and 'onRehydrateStorage' are called with
// the latest available state.
hasHydrated = false
hydrationListeners.forEach((cb) => cb(get() ?? configResult))
const postRehydrationCallback =
options.onRehydrateStorage?.(get() ?? configResult) || undefined
// bind is used to avoid `TypeError: Illegal invocation` error
return toThenable(storage.getItem.bind(storage))(options.name)
.then((deserializedStorageValue) => {
if (deserializedStorageValue) {
if (
typeof deserializedStorageValue.version === 'number' &&
deserializedStorageValue.version !== options.version
) {
if (options.migrate) {
return [
true,
options.migrate(
deserializedStorageValue.state,
deserializedStorageValue.version,
),
] as const
}
console.error(
`State loaded from storage couldn't be migrated since no migrate function was provided`,
)
} else {
return [false, deserializedStorageValue.state] as const
}
}
return [false, undefined] as const
})
.then((migrationResult) => {
const [migrated, migratedState] = migrationResult
stateFromStorage = options.merge(
migratedState as S,
get() ?? configResult,
)
set(stateFromStorage as S, true)
if (migrated) {
return setItem()
}
})
.then(() => {
// TODO: In the asynchronous case, it's possible that the state has changed
// since it was set in the prior callback. As such, it would be better to
// pass 'get()' to the 'postRehydrationCallback' to ensure the most up-to-date
// state is used. However, this could be a breaking change, so this isn't being
// done now.
postRehydrationCallback?.(stateFromStorage, undefined)
// It's possible that 'postRehydrationCallback' updated the state. To ensure
// that isn't overwritten when returning 'stateFromStorage' below
// (synchronous-case only), update 'stateFromStorage' to point to the latest
// state. In the asynchronous case, 'stateFromStorage' isn't used after this
// callback, so there's no harm in updating it to match the latest state.
stateFromStorage = get()
hasHydrated = true
finishHydrationListeners.forEach((cb) => cb(stateFromStorage as S))
})
.catch((e: Error) => {
postRehydrationCallback?.(undefined, e)
})
}
;(api as StoreApi<S> & StorePersist<S, S>).persist = {
setOptions: (newOptions) => {
options = {
...options,
...newOptions,
}
if (newOptions.storage) {
storage = newOptions.storage
}
},
clearStorage: () => {
storage?.removeItem(options.name)
},
getOptions: () => options,
rehydrate: () => hydrate() as Promise<void>,
hasHydrated: () => hasHydrated,
onHydrate: (cb) => {
hydrationListeners.add(cb)
return () => {
hydrationListeners.delete(cb)
}
},
onFinishHydration: (cb) => {
finishHydrationListeners.add(cb)
return () => {
finishHydrationListeners.delete(cb)
}
},
}
if (!options.skipHydration) {
hydrate()
}
return stateFromStorage || configResult
}
이부분이 presist의 핵심 로직이다.
간단하게만 이야기 하면 기본 옵션을 설정하고, 저장소 존재 여부를 확인한 후, 저장된 상태와 현재 상태를 불러와 버전을 비교하여 버전이 다르면 마이그레이션을 수행하고, 저장된 상태와 현재 상태를 병합해서 최종 상태를 만듦으로써 zustand의 상태를 영구 저장소와 동기화하는 함수이다.
이제 천천히 알아보자
const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
type S = ReturnType<typeof config>
let options = {
storage: createJSONStorage<S>(() => localStorage), // 기본 저장소
partialize: (state: S) => state, // 상태 필터링
version: 0, // 버전
merge: (persistedState: unknown, currentState: S) => ({
...currentState,
...(persistedState as object),
}),
...baseOptions, // 사용자 옵션으로 덮어쓰기
}
let hasHydrated = false
const hydrationListeners = new Set<PersistListener<S>>()
const finishHydrationListeners = new Set<PersistListener<S>>()
let storage = options.storage
if (!storage) {
return config(
(...args) => {
console.warn(...)
set(...(args as Parameters<typeof set>))
},
get,
api,
)
}
const setItem = () => {
const state = options.partialize({ ...get() })
return storage.setItem(options.name, {
state,
version: options.version,
})
}
const savedSetState = api.setState
api.setState = (state, replace) => {
savedSetState(state, replace as any)
void setItem()
}
const configResult = config(
(...args) => {
set(...(args as Parameters<typeof set>))
void setItem()
},
get,
api,
)
api.getInitialState = () => configResult
const hydrate = () => {
// 저장소가 없으면 아무것도 반환하지 않음
if (!storage) return
// 상태 복원 시작을 표시하고 리스너들에게 알림
hasHydrated = false
hydrationListeners.forEach((cb) => cb(get() ?? configResult))
// 상태 복원 전 콜백 설정
const postRehydrationCallback =
options.onRehydrateStorage?.(get() ?? configResult) || undefined
// storage.getItem을 Promise로 변환해서 실행
return toThenable(storage.getItem.bind(storage))(options.name)
.then(버전 체크 및 마이그레이션함수)
.then(상태 병합 및 적용)
.then(복원 완료 처리)
.catch(에러 처리)
}
(api as StoreApi<S> & StorePersist<S, S>).persist = {
// 옵션 설정/변경
setOptions: (newOptions) => {
options = { ...options, ...newOptions }
if (newOptions.storage) storage = newOptions.storage
},
// 저장소 초기화
clearStorage: () => storage?.removeItem(options.name),
// 현재 옵션 가져오기
getOptions: () => options,
// 상태 복원 실행
rehydrate: () => hydrate() as Promise<void>,
// 복원 완료 여부 확인
hasHydrated: () => hasHydrated,
// 복원 시작 리스너 추가/제거
onHydrate: (cb) => {
hydrationListeners.add(cb)
return () => hydrationListeners.delete(cb)
},
// 복원 완료 리스너 추가/제거
onFinishHydration: (cb) => {
finishHydrationListeners.add(cb)
return () => finishHydrationListeners.delete(cb)
},
}
// skipHydration 옵션이 false면 hydrate 실행
if (!options.skipHydration) {
hydrate()
}
// 저장된 상태나 초기 상태 반환
return stateFromStorage || configResult
import type {
StateCreator,
StoreApi,
StoreMutatorIdentifier,
} from '../vanilla.ts'
export interface StateStorage {
getItem: (name: string) => string | null | Promise<string | null>
setItem: (name: string, value: string) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
export type StorageValue<S> = {
state: S
version?: number
}
export interface PersistStorage<S> {
getItem: (
name: string,
) => StorageValue<S> | null | Promise<StorageValue<S> | null>
setItem: (name: string, value: StorageValue<S>) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
type JsonStorageOptions = {
reviver?: (key: string, value: unknown) => unknown
replacer?: (key: string, value: unknown) => unknown
}
export function createJSONStorage<S>(
getStorage: () => StateStorage,
options?: JsonStorageOptions,
): PersistStorage<S> | undefined {
let storage: StateStorage | undefined
try {
storage = getStorage()
} catch {
// prevent error if the storage is not defined (e.g. when server side rendering a page)
return
}
const persistStorage: PersistStorage<S> = {
getItem: (name) => {
const parse = (str: string | null) => {
if (str === null) {
return null
}
return JSON.parse(str, options?.reviver) as StorageValue<S>
}
const str = (storage as StateStorage).getItem(name) ?? null
if (str instanceof Promise) {
return str.then(parse)
}
return parse(str)
},
setItem: (name, newValue) =>
(storage as StateStorage).setItem(
name,
JSON.stringify(newValue, options?.replacer),
),
removeItem: (name) => (storage as StateStorage).removeItem(name),
}
return persistStorage
}
export interface PersistOptions<S, PersistedState = S> {
/** Name of the storage (must be unique) */
name: string
/**
* Use a custom persist storage.
*
* Combining `createJSONStorage` helps creating a persist storage
* with JSON.parse and JSON.stringify.
*
* @default createJSONStorage(() => localStorage)
*/
storage?: PersistStorage<PersistedState> | undefined
/**
* Filter the persisted value.
*
* @params state The state's value
*/
partialize?: (state: S) => PersistedState
/**
* A function returning another (optional) function.
* The main function will be called before the state rehydration.
* The returned function will be called after the state rehydration or when an error occurred.
*/
onRehydrateStorage?: (
state: S,
) => ((state?: S, error?: unknown) => void) | void
/**
* If the stored state's version mismatch the one specified here, the storage will not be used.
* This is useful when adding a breaking change to your store.
*/
version?: number
/**
* A function to perform persisted state migration.
* This function will be called when persisted state versions mismatch with the one specified here.
*/
migrate?: (
persistedState: unknown,
version: number,
) => PersistedState | Promise<PersistedState>
/**
* A function to perform custom hydration merges when combining the stored state with the current one.
* By default, this function does a shallow merge.
*/
merge?: (persistedState: unknown, currentState: S) => S
/**
* An optional boolean that will prevent the persist middleware from triggering hydration on initialization,
* This allows you to call `rehydrate()` at a specific point in your apps rendering life-cycle.
*
* This is useful in SSR application.
*
* @default false
*/
skipHydration?: boolean
}
type PersistListener<S> = (state: S) => void
type StorePersist<S, Ps> = {
persist: {
setOptions: (options: Partial<PersistOptions<S, Ps>>) => void
clearStorage: () => void
rehydrate: () => Promise<void> | void
hasHydrated: () => boolean
onHydrate: (fn: PersistListener<S>) => () => void
onFinishHydration: (fn: PersistListener<S>) => () => void
getOptions: () => Partial<PersistOptions<S, Ps>>
}
}
type Thenable<Value> = {
then<V>(
onFulfilled: (value: Value) => V | Promise<V> | Thenable<V>,
): Thenable<V>
catch<V>(
onRejected: (reason: Error) => V | Promise<V> | Thenable<V>,
): Thenable<V>
}
const toThenable =
<Result, Input>(
fn: (input: Input) => Result | Promise<Result> | Thenable<Result>,
) =>
(input: Input): Thenable<Result> => {
try {
const result = fn(input)
if (result instanceof Promise) {
return result as Thenable<Result>
}
return {
then(onFulfilled) {
return toThenable(onFulfilled)(result as Result)
},
catch(_onRejected) {
return this as Thenable<any>
},
}
} catch (e: any) {
return {
then(_onFulfilled) {
return this as Thenable<any>
},
catch(onRejected) {
return toThenable(onRejected)(e)
},
}
}
}
const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
type S = ReturnType<typeof config>
let options = {
storage: createJSONStorage<S>(() => localStorage),
partialize: (state: S) => state,
version: 0,
merge: (persistedState: unknown, currentState: S) => ({
...currentState,
...(persistedState as object),
}),
...baseOptions,
}
let hasHydrated = false
const hydrationListeners = new Set<PersistListener<S>>()
const finishHydrationListeners = new Set<PersistListener<S>>()
let storage = options.storage
if (!storage) {
return config(
(...args) => {
console.warn(
`[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`,
)
set(...(args as Parameters<typeof set>))
},
get,
api,
)
}
const setItem = () => {
const state = options.partialize({ ...get() })
return (storage as PersistStorage<S>).setItem(options.name, {
state,
version: options.version,
})
}
const savedSetState = api.setState
api.setState = (state, replace) => {
savedSetState(state, replace as any)
void setItem()
}
const configResult = config(
(...args) => {
set(...(args as Parameters<typeof set>))
void setItem()
},
get,
api,
)
api.getInitialState = () => configResult
// a workaround to solve the issue of not storing rehydrated state in sync storage
// the set(state) value would be later overridden with initial state by create()
// to avoid this, we merge the state from localStorage into the initial state.
let stateFromStorage: S | undefined
// rehydrate initial state with existing stored state
const hydrate = () => {
if (!storage) return
// On the first invocation of 'hydrate', state will not yet be defined (this is
// true for both the 'asynchronous' and 'synchronous' case). Pass 'configResult'
// as a backup to 'get()' so listeners and 'onRehydrateStorage' are called with
// the latest available state.
hasHydrated = false
hydrationListeners.forEach((cb) => cb(get() ?? configResult))
const postRehydrationCallback =
options.onRehydrateStorage?.(get() ?? configResult) || undefined
// bind is used to avoid `TypeError: Illegal invocation` error
return toThenable(storage.getItem.bind(storage))(options.name)
.then((deserializedStorageValue) => {
if (deserializedStorageValue) {
if (
typeof deserializedStorageValue.version === 'number' &&
deserializedStorageValue.version !== options.version
) {
if (options.migrate) {
return [
true,
options.migrate(
deserializedStorageValue.state,
deserializedStorageValue.version,
),
] as const
}
console.error(
`State loaded from storage couldn't be migrated since no migrate function was provided`,
)
} else {
return [false, deserializedStorageValue.state] as const
}
}
return [false, undefined] as const
})
.then((migrationResult) => {
const [migrated, migratedState] = migrationResult
stateFromStorage = options.merge(
migratedState as S,
get() ?? configResult,
)
set(stateFromStorage as S, true)
if (migrated) {
return setItem()
}
})
.then(() => {
// TODO: In the asynchronous case, it's possible that the state has changed
// since it was set in the prior callback. As such, it would be better to
// pass 'get()' to the 'postRehydrationCallback' to ensure the most up-to-date
// state is used. However, this could be a breaking change, so this isn't being
// done now.
postRehydrationCallback?.(stateFromStorage, undefined)
// It's possible that 'postRehydrationCallback' updated the state. To ensure
// that isn't overwritten when returning 'stateFromStorage' below
// (synchronous-case only), update 'stateFromStorage' to point to the latest
// state. In the asynchronous case, 'stateFromStorage' isn't used after this
// callback, so there's no harm in updating it to match the latest state.
stateFromStorage = get()
hasHydrated = true
finishHydrationListeners.forEach((cb) => cb(stateFromStorage as S))
})
.catch((e: Error) => {
postRehydrationCallback?.(undefined, e)
})
}
;(api as StoreApi<S> & StorePersist<S, S>).persist = {
setOptions: (newOptions) => {
options = {
...options,
...newOptions,
}
if (newOptions.storage) {
storage = newOptions.storage
}
},
clearStorage: () => {
storage?.removeItem(options.name)
},
getOptions: () => options,
rehydrate: () => hydrate() as Promise<void>,
hasHydrated: () => hasHydrated,
onHydrate: (cb) => {
hydrationListeners.add(cb)
return () => {
hydrationListeners.delete(cb)
}
},
onFinishHydration: (cb) => {
finishHydrationListeners.add(cb)
return () => {
finishHydrationListeners.delete(cb)
}
},
}
if (!options.skipHydration) {
hydrate()
}
return stateFromStorage || configResult
}
type Persist = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
>(
initializer: StateCreator<T, [...Mps, ['zustand/persist', unknown]], Mcs>,
options: PersistOptions<T, U>,
) => StateCreator<T, Mps, [['zustand/persist', U], ...Mcs]>
declare module '../vanilla' {
interface StoreMutators<S, A> {
'zustand/persist': WithPersist<S, A>
}
}
type Write<T, U> = Omit<T, keyof U> & U
type WithPersist<S, A> = S extends { getState: () => infer T }
? Write<S, StorePersist<T, A>>
: never
type PersistImpl = <T>(
storeInitializer: StateCreator<T, [], []>,
options: PersistOptions<T, T>,
) => StateCreator<T, [], []>
export const persist = persistImpl as unknown as Persist