본문으로 바로가기
윤창원uiwwsw · 작은 우주

개발과 기술

useStorage — 같은 창과 다른 탭의 변경을 구독하는 React 훅

2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.

여러 컴포넌트에서 같은 localStorage 값을 쓸 때, 저장된 값과 화면의 상태가 어긋나지 않게 하고 싶었다.

여기서 먼저 확인할 점이 있다. 브라우저의 storage 이벤트는 변경을 일으킨 창 자체에는 발생하지 않는다. 같은 출처의 다른 탭에서 일어난 localStorage 변경을 받는 것과, 현재 창의 다른 컴포넌트에 변경을 알리는 것은 따로 처리해야 한다. MDN: storage 이벤트

이전 예시의 상태 복제와 지연 쓰기를 다시 검토하며, 저장소의 현재 문자열을 구독하는 형태로 정리했다. 아래는 글을 위한 수정 예시다.

구현 범위

useSyncExternalStore로 원시 문자열 스냅샷을 구독하고, JSON 파싱은 그 다음에 한다. 스냅샷을 읽을 때마다 새 객체를 만들지 않기 위해서다. 서버 렌더링과 최초 hydration에서는 동일한 기본값을 사용한다. React: useSyncExternalStore

import { useCallback, useMemo, useSyncExternalStore } from 'react';

const CHANGE_EVENT = 'app:local-storage-change';
const getServerSnapshot = () => null;

function readStorage(key: string): string | null {
  if (typeof window === 'undefined') return null;
  try {
    return window.localStorage.getItem(key);
  } catch {
    return null;
  }
}

export function useStorage<T>(
  key: string,
  initialValue: T,
  decode: (input: unknown) => T | undefined,
) {
  const getSnapshot = useCallback(() => readStorage(key), [key]);

  const subscribe = useCallback((notify: () => void) => {
    const handleStorage = (event: StorageEvent) => {
      try {
        if (event.storageArea !== window.localStorage) return;
      } catch {
        return;
      }
      if (event.key === key || event.key === null) notify();
    };

    const handleLocal = (event: Event) => {
      if (event instanceof CustomEvent && event.detail === key) notify();
    };

    window.addEventListener('storage', handleStorage);
    window.addEventListener(CHANGE_EVENT, handleLocal);
    return () => {
      window.removeEventListener('storage', handleStorage);
      window.removeEventListener(CHANGE_EVENT, handleLocal);
    };
  }, [key]);

  const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  const value = useMemo(() => {
    if (raw === null) return initialValue;
    try {
      const decoded = decode(JSON.parse(raw));
      return decoded === undefined ? initialValue : decoded;
    } catch {
      return initialValue;
    }
  }, [raw, decode, initialValue]);

  const setValue = useCallback((next: T | undefined): boolean => {
    if (typeof window === 'undefined') return false;
    try {
      if (next === undefined) {
        window.localStorage.removeItem(key);
      } else {
        const encoded = JSON.stringify(next);
        if (encoded === undefined) return false;
        window.localStorage.setItem(key, encoded);
      }
      window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: key }));
      return true;
    } catch {
      return false;
    }
  }, [key]);

  return [value, setValue] as const;
}

사용하기

타입 매개변수만으로 브라우저에 저장된 JSON의 형식을 보장할 수는 없다. 읽은 값을 확인하는 함수를 함께 받는다.

const decodeName = (input: unknown) =>
  typeof input === 'string' ? input : undefined;

function NameSetting() {
  const [name, setName] = useStorage('my-name', 'Anonymous', decodeName);
  return (
    <button type="button" onClick={() => {
      if (!setName('Alice')) window.alert('이름을 저장하지 못했습니다.');
    }}>
      현재 이름: {name}
    </button>
  );
}

기본값은 저장소에 자동으로 쓰지 않는다. 값이 없거나 파싱·검증에 실패하면 화면에서 기본값을 사용한다. SSR에서는 서버와 클라이언트가 같은 initialValue를 전달해야 하고, hydration 이후 저장값으로 바뀔 수 있다.

이 구현은 쓰기를 즉시 수행한다. 실제 저장 전에 화면에만 성공한 값을 알리던 순서 문제를 피하고, 실패 여부를 호출자에게 반환하기 위해서다. 쓰기 지연이 필요한 입력창이라면 입력 중인 상태와 저장된 상태를 구분해서 설계하는 편이 명확하다.

현재 창의 동기화는 이 훅의 setValue를 통해 쓴다는 규칙에 의존한다. 다른 코드가 현재 창에서 localStorage.setItem을 직접 호출하면 커스텀 이벤트를 발생시키지 않으므로 즉시 통지되지 않는다. 다른 탭의 변경과 clear()는 브라우저 이벤트로 확인하며, 탭의 포커스 여부로 걸러내지 않는다.

원자적인 여러 탭 간 갱신이나 충돌 해결까지 제공하는 저장소는 아니다. 동시에 쓰면 나중에 저장된 값이 남는다. 여기서 다룬 범위는 JSON으로 저장할 수 있는 간단한 설정값의 읽기, 쓰기, 변경 구독이다.

Assisted by AI

윤창원이 벨로그에 남긴 글을 이 작은 우주에도 모았습니다. 사진은 누르면 원본 크기로 볼 수 있습니다. 원문의 전체 서식 보기 ↗

모든 글 둘러보기 →