OSS

useJsonLocalStorage: JSON 형식으로 LocalStorage를 다루는 React Hook 개발기

·

TL;DR

useJsonLocalStorage는 JSON 형식으로 LocalStorage를 다루기 위한 React Hook입니다.

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라 접근 패턴이 지저분해진다

이 문제들을 해결하기 위해 다음을 목표로 삼았습니다.

  1. LocalStorage 접근 과정을 추상화하고
  2. 타입 추론이 가능한 API를 제공하며
  3. 런타임에서의 변경을 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스럽게 다루고 싶은 분들에게 의미 있는 선택지가 되기를 바랍니다.

앞으로도 이러한 도구들을 계속 만들어보고 싶습니다. 읽어주셔서 감사합니다.