Skip to content

Instantly share code, notes, and snippets.

@Jonghakseo
Last active November 23, 2024 04:47
Show Gist options
  • Save Jonghakseo/0f708ee89b0b8b917623b8cd4cd81f88 to your computer and use it in GitHub Desktop.
Save Jonghakseo/0f708ee89b0b8b917623b8cd4cd81f88 to your computer and use it in GitHub Desktop.
Zustand sync with querystring (for SPA)
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);
}
@Jonghakseo
Copy link
Author

Jonghakseo commented Nov 2, 2024

Usage

export const useSomeStore = create((set, _, api) => ({
  ...syncStoreWithURL({
    api,
    allowedPaths: ['/', '/list'],
    syncTarget: {
      page: 1,  // When you want to sync some value with querystring
    },
  }),
  setPage: (page) => set({ page }),  // Auto sync after value changed
  searchKeyword: undefined,  // When you don't want to sync some value with querystring
}));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment