- 개요
- TL;DR
- 무엇을 만들었는가?
- 왜 만들었는가?
- 어떻게 해결했는가?
- 1. 단순한 LocalStorage 접근 훅
- 2. JSON 기반 API 래핑
- 3. Hook이 아닌, 싱글톤 Runtime Store
- 4. React state와의 동기화 문제
- 5. 최종 구조
- 마무리
TL;DR
useJsonLocalStorage는 JSON 형식으로 LocalStorage를 다루기 위한 React Hook입니다.
- https://www.npmjs.com/package/use-json-localstorage
- https://github.com/dohyeon-kr/use-json-localstorage
LocalStorage를 단순한 문자열 저장소가 아니라,
타입이 유지되는 외부 상태 저장소처럼 다루는 것을 목표로 만들었습니다.
무엇을 만들었는가?
import { useLocalStorageValue, getRuntimeLocalStorage } from 'use-json-localstorage';
function Counter() {
const count = useLocalStorageValue('count', 0);
const runtimeStorage = getRuntimeLocalStorage();
const onClick = () => {
runtimeStorage.set('count', count + 1);
};
return (
<div>
<div>{count}</div>
<button onClick={onClick}>Increment</button>
</div>
);
}
이 라이브러리는 다음을 제공합니다.
- LocalStorage를 JSON 기반으로 직렬화/역직렬화
useSyncExternalStore를 이용한 React state와의 동기화- 런타임에서 LocalStorage를 직접 제어할 수 있는 싱글톤 API
왜 만들었는가?
현재 개발 중인 사이드 프로젝트의 초기 MVP에서는
백엔드를 빠르게 키우기보다, 가능한 한 클라이언트 중심의 구조로 가고 싶었습니다.
그래서 간단한 데이터 저장소로 LocalStorage를 선택했지만, 곧 몇 가지 불편함이 드러났습니다.
- 문자열만 저장 가능하다
- 타입이 보존되지 않는다
- React state와 자연스럽게 동기화되지 않는다
- client-only API라 접근 패턴이 지저분해진다
이 문제들을 해결하기 위해 다음을 목표로 삼았습니다.
- LocalStorage 접근 과정을 추상화하고
- 타입 추론이 가능한 API를 제공하며
- 런타임에서의 변경을 React state와 동기화하는 것
어떻게 해결했는가?
1. 단순한 LocalStorage 접근 훅
초기에는 client 환경에서 LocalStorage 접근을 보조하는 간단한 훅으로 시작했습니다.
const useLocalStorage = (callback) => {
useEffect(() => {
if (!globalThis.localStorage) return;
callback(globalThis.localStorage);
}, [callback]);
};
하지만 곧 타입 추론에 대한 니즈가 생겼고,
이를 위해 LocalStorage API를 그대로 노출하기보다 감싼 형태의 API가 필요하다고 판단했습니다.
2. JSON 기반 API 래핑
const useLocalStorage = (callback) => {
useEffect(() => {
if (!globalThis.localStorage) return;
callback({
get: (key) => JSON.parse(globalThis.localStorage.getItem(key)),
set: (key, value) =>
globalThis.localStorage.setItem(key, JSON.stringify(value)),
});
}, [callback]);
};
이 방식으로 클라이언트 접근 추상화와 타입 추론이라는 목표는 달성할 수 있었습니다.
하지만 곧 한계가 드러났습니다.
Set,Map,Date,RegExp같은 타입은 JSON으로 표현할 수 없다
이를 해결하기 위해 superjson을 도입했습니다.
import SuperJSON from "superjson";
const useLocalStorage = (callback) => {
useEffect(() => {
if(!globalThis.localStorage) return;
callback({
get: (key) => {
return SuperJSON.parse(globalThis.localStorage.getItem(key));
},
set: (key, value) => {
globalThis.localStorage.setItem(key, SuperJSON.stringify(value));
},
});
}, [callback]);
}
3. Hook이 아닌, 싱글톤 Runtime Store
구조를 정리한 뒤 ChatGPT에게 리뷰를 요청했고,
“이 로직은 Hook일 필요가 없다”는 피드백을 받았습니다.
LocalStorage의 subset API를 반환하는 싱글톤 함수로 만드는 편이 더 명확했고,
이 판단은 결과적으로 구조를 단순하게 만들어주었습니다.
export const getRuntimeLocalStorage = () => {
if (!(globalThis as ExtendedGlobalThis).runtimeLocalStorage) {
(globalThis as ExtendedGlobalThis).runtimeLocalStorage = (() => {
return {
set: (key: string, value: unknown) => {
globalThis.localStorage.setItem(key, SuperJSON.stringify(value));
},
get: <T>(key: string) => {
const value = globalThis.localStorage.getItem(key);
return value ? SuperJSON.parse<T>(value) : null;
},
remove: (key: string) => {
globalThis.localStorage.removeItem(key);
},
};
})();
}
return (globalThis as ExtendedGlobalThis).runtimeLocalStorage;
};
4. React state와의 동기화 문제
이제 남은 문제는 LocalStorage 변경을 React state와 어떻게 동기화할 것인가였습니다.
처음에는 EventEmitter + useEffect 조합으로 해결하려 했습니다. 하지만 다시 리뷰를 받는 과정에서 두 가지 문제가 드러났습니다.
EventEmitter는 Node 종속이다
외부 저장소를 React와 동기화하는 방식으로는 더 이상 권장되지 않는다
React 팀은 이러한 문제를 해결하기 위해 useSyncExternalStore 를 공식 API로 제공하고 있습니다.
이에 따라 구현을 전면 수정했습니다.
5. 최종 구조
'use client';
import SuperJSON from "superjson";
interface SerializeStorage {
set: <T>(key: string, value: T) => void;
get: <T>(key: string) => T | null;
remove: (key: string) => void;
on: (type: string, callback: (event: LocalStorageEvent) => void) => () => void;
}
type ExtendedGlobalThis = typeof globalThis & {
runtimeLocalStorage: SerializeStorage;
};
function deserialize<T>(serializedJavascript: string): T {
return SuperJSON.parse(serializedJavascript);
}
type Listener<T> = (event: T) => void;
type LocalStorageEvent = {
key: string;
value?: unknown;
};
function createEmitter<T>() {
const listeners = new Map<string, Set<Listener<T>>>();
return {
emit(type: string, event: T) {
listeners.get(type)?.forEach((listener) => listener(event));
},
on(type: string, listener: Listener<T>) {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
listeners.get(type)!.add(listener);
return () => listeners.get(type)!.delete(listener);
}
};
}
export const getRuntimeLocalStorage = () => {
if (!(globalThis as ExtendedGlobalThis).runtimeLocalStorage) {
(globalThis as ExtendedGlobalThis).runtimeLocalStorage = (() => {
const emitter = createEmitter<LocalStorageEvent>();
return {
set: (key: string, value: unknown) => {
const serializedValue = SuperJSON.stringify(value);
globalThis.localStorage.setItem(key, serializedValue);
emitter.emit(`set`, { key, value });
return;
},
get: <T>(key: string) => {
const value = globalThis.localStorage.getItem(key);
return value ? deserialize<T>(value) : null;
},
// remove API 니즈가 있을 것을 깨달아 추가하였습니다.
remove: (key: string) => {
globalThis.localStorage.removeItem(key);
emitter.emit(`remove`, { key });
return;
},
on: (type: string, callback: Listener<LocalStorageEvent>) => {
return emitter.on(type, callback)
},
};
})();
};
return (globalThis as ExtendedGlobalThis).runtimeLocalStorage;
}
'use client';
import { useRef, useSyncExternalStore } from "react";
import { getRuntimeLocalStorage } from "./getRuntimeLocalStorage";
export const useLocalStorageValue = <T>(key: string, defaultValue?: T) => {
const store = useRef(getRuntimeLocalStorage());
return useSyncExternalStore<T | undefined>(
(onStoreChange) => {
const setUnsubscribe = store.current.on('set', (event) => {
if (event.key === key) {
onStoreChange();
}
});
const removeUnsubscribe = store.current.on('remove', (event) => {
if (event.key === key) {
onStoreChange();
}
});
return () => {
setUnsubscribe();
removeUnsubscribe();
};
},
() => {
const value = store.current.get<T>(key);
return value ?? defaultValue;
},
() => {
// 서버에서는 default값 보다 '없다'라는 정보를 표현하고 싶어서 undefined를 반환하도록 했습니다.
return undefined;
}
);
};
이로써 동시성 환경에서도 안전하게 동작하는 구조를 완성할 수 있었습니다.
마무리
이러한 과정을 거쳐 라이브러리를 npm에 배포했고, GitHub Actions를 통한 배포 자동화까지 마무리했습니다.
작은 라이브러리이지만, LocalStorage를 조금 더 React스럽게 다루고 싶은 분들에게 의미 있는 선택지가 되기를 바랍니다.
앞으로도 이러한 도구들을 계속 만들어보고 싶습니다. 읽어주셔서 감사합니다.