Last active
November 23, 2024 04:47
-
-
Save Jonghakseo/0f708ee89b0b8b917623b8cd4cd81f88 to your computer and use it in GitHub Desktop.
Zustand sync with querystring (for SPA)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { StoreApi } from 'zustand/vanilla'; | |
type RemoveFunctionFromObject<T> = { | |
[key in keyof T as T[key] extends Function ? never : key]: T[key]; | |
}; | |
type CustomSerializeDeserialize<Value> = { | |
serialize?: (value: Value) => string; | |
deserialize?: (value: string) => Value; | |
}; | |
type StateFromAPI<API extends StoreApi<any>> = API extends StoreApi<infer State> ? State : never; | |
/** | |
* Store의 상태를 URL의 query string으로 동기화하는 유틸 함수입니다.<br/> | |
* 커스텀 직렬화/역직렬화 로직을 주입할 수 있습니다.<br/> | |
* number, string 등 primitive 값과 enum, 배열 등 자주 사용하는 자료형에 대한 기본 직렬화/역직렬화 로직이 내장되어 있습니다. | |
* | |
* @param api zustand Store의 API 객체입니다. | |
* @param allowedPaths 동기화할 URL path를 명시합니다. (ex. ['/blog']) | |
* @param syncTarget Store의 상태 중 동기화할 필드들을 정의합니다. | |
* @param customSerializeDeserialize 커스텀 직렬화/역직렬화 로직을 주입할 필드를 객체 형태로 정의합니다. (옵셔널) | |
* | |
* @example 기본값만 명시하고 커스텀 직렬화/역직렬화 로직이 불필요한 경우 | |
* ```tsx | |
* export const useStore = create<State & SetterActionFromState<State>( | |
* (set, _, api) => ({ | |
* ...syncStoreWithURL({ | |
* api, | |
* allowedPaths: ["/path"], | |
* syncTarget: { | |
* page: 1, | |
* search: undefined, | |
* }}), | |
* //... | |
* }), | |
* ); | |
* ``` | |
* | |
* @example 커스텀 직렬화/역직렬화 로직이 필요한 경우 | |
* | |
* ```tsx | |
* export const useStore = create<State & SetterActionFromState<State>( | |
* (set, _, api) => ({ | |
* ...syncStoreWithURL({ | |
* api, | |
* allowedPaths: ["/path"], | |
* syncTarget: { | |
* page: 1, | |
* search: undefined, | |
* types: [], | |
* }, | |
* customSerializeDeserialize: { | |
* // 커스텀 직렬화/역직렬화 로직이 필요한 필드만 명시합니다. | |
* types: { | |
* serialize: (types) => types.join(','), | |
* deserialize: (value) => value.split(','), | |
* }, | |
* }, | |
* }), | |
* //... | |
* }), | |
* ); | |
* ``` | |
*/ | |
export const syncStoreWithURL = < | |
API extends StoreApi<any>, | |
State extends StateFromAPI<API>, | |
SyncTarget extends { | |
[key in keyof Partial<RemoveFunctionFromObject<State>>]: State[key]; | |
}, | |
>({ | |
api, | |
allowedPaths, | |
syncTarget, | |
customSerializeDeserialize, | |
}: { | |
api: API; | |
allowedPaths: string[]; | |
syncTarget: SyncTarget; | |
customSerializeDeserialize?: { | |
[key in keyof Partial<SyncTarget>]: CustomSerializeDeserialize<State[key]>; | |
}; | |
}): { | |
[key in keyof State & keyof SyncTarget]: State[key]; | |
} => { | |
function getCustomSerializeLogic(key: keyof SyncTarget) { | |
return customSerializeDeserialize?.[key] ?? {}; | |
} | |
api.subscribe((state) => { | |
const url = getCurrentURLWithoutSearch(); | |
if (!allowedPaths.includes(url.pathname)) { | |
return; | |
} | |
Object.entries(syncTarget).forEach(([name, initialValue]) => { | |
const key = name as keyof SyncTarget & string; | |
const { serialize = defaultSerialize } = getCustomSerializeLogic(key); | |
const needUpdate = serialize(state[key]) !== serialize(initialValue); | |
if (needUpdate) { | |
url.searchParams.set(key, serialize(state[key])); | |
} | |
}); | |
history.replaceState(history.state, '', url); | |
}); | |
return Object.entries(syncTarget).reduce((acc, cur) => { | |
const [key, initialValue] = cur as [keyof SyncTarget & string, State[keyof State]]; | |
const { deserialize = defaultDeserialize } = getCustomSerializeLogic(key); | |
const getValue = () => { | |
const currentURL = getCurrentURL(); | |
if (allowedPaths.includes(currentURL.pathname)) { | |
const value = currentURL.searchParams.get(key); | |
if (value) { | |
return deserialize(value); | |
} | |
} | |
return initialValue; | |
}; | |
return { ...acc, [key]: getValue() }; | |
}, {} as { [key in keyof State & keyof SyncTarget]: State[key] }); | |
}; | |
function getCurrentURL() { | |
return new URL(window.location.href); | |
} | |
function getCurrentURLWithoutSearch() { | |
return new URL(window.location.origin + window.location.pathname); | |
} | |
function defaultSerialize(value: any) { | |
if (Array.isArray(value)) { | |
return defaultArraySerialize(value); | |
} | |
return defaultSerializeForSingleValue(value); | |
} | |
function defaultSerializeForSingleValue(value: any) { | |
switch (value) { | |
case null: | |
case undefined: | |
case '': | |
return ''; | |
default: { | |
if (typeof value === 'object') { | |
return JSON.stringify(value); | |
} | |
return `${value}`; | |
} | |
} | |
} | |
function defaultDeserialize(value: string) { | |
if (isDefaultSerializedArray(value)) { | |
return defaultArrayDeserialize(value); | |
} | |
return defaultDeserializeForSingleValue(value); | |
} | |
function defaultDeserializeForSingleValue(value: string) { | |
if (isParsableNumber(value)) { | |
return Number(value); | |
} | |
try { | |
return JSON.parse(value); | |
} catch { | |
switch (value) { | |
case 'true': | |
return true; | |
case 'false': | |
return false; | |
case '': | |
return undefined; | |
default: | |
return value; | |
} | |
} | |
} | |
function isParsableNumber(value: string) { | |
return !Number.isNaN(Number(value)); | |
} | |
function defaultArraySerialize(value: unknown[]) { | |
return `a[${value.map(defaultSerializeForSingleValue).join(',')}]`; | |
} | |
function isDefaultSerializedArray(value: string) { | |
return value.startsWith('[') && value.endsWith(']'); | |
} | |
function defaultArrayDeserialize(value: string) { | |
return value.replace('[', '').replace(']', '').split(',').map(defaultDeserializeForSingleValue); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage