useSyncExternalStore로 localStorage를 상태처럼 사용하기

aken·2025년 4월 4일
2
post-thumbnail

useSyncExternalStore 훅을 들어본 적 있으신가요?
이 훅은 React 18버전에 등장했으나, 저는 최근에 알았습니다 💦

localStorage에 저장하고 있는 값을 사용하고 있었는데요. 만약 사용자가 개발자도구를 켜서 localStorage의 값을 바꾼다면 어떻게 될까요? React는 이 변경을 알아차릴까요?

localStorage는 web API라 외부 상태에 속합니다. 그래서, React에서 localStorage의 변경을 감지하고 싶다면 구독해야 합니다.
이런 경우에 useSyncExternalStore 훅을 사용할 수 있습니다.

useSyncExternalStore

useSyncExternalStore는 외부 store를 구독할 수 있는 React Hook입니다. - 공식문서

여기서 말하는 외부 store는 무엇을 가리키는 걸까요?

React에서 주로 state, props, context로 데이터를 관리합니다. 하지만, 이 외에 외부에서 저장된 데이터도 관리해야 하는 경우가 있습니다.

  • React 외부에 state를 저장하는 상태 관리 라이브러리
  • 변경 가능한 값을 저장하는 브라우저 API와 그 값을 구독하는 이벤트

이 경우들을 외부 store라고 생각하시면 됩니다.

사용법

주로 커스텀 훅에서 호출합니다.

export const useStorage = (key: string, initialValue: UserRecordType[]) => {
  const store = useSyncExternalStore(subscribe, getSnapshot);

  // ...
};
  • subscribe: store를 구독하는 callback 함수
    • store가 변경될 때, callback 호출 -> getSnapshot 호출 -> 컴포넌트 렌더링 과정이 일어납니다.
    • subscribe 함수는 구독을 해지하는 함수를 반환해야 합니다.
  • getSnapshot: store의 스냅샷을 반환하는 함수 (상태를 반환하는 함수)
    • store가 변경되지 않는다면, getSnapshot은 동일한 값을 반환합니다. (Object.is로 비교하여 변경 감지)
    • Object.is로 비교하여 store가 변경되면, React는 컴포넌트를 리렌더링합니다.

localStorage 구독하기

사용법까지 알았으니 이제 훅으로 localStorage를 구독해볼까요?

커스텀 훅 정의

특정 key에 해당하는 값을 구독하기 위해 인수로 key 값을 받습니다.
그리고 나서, 커스텀 훅 안에 useSyncExternalStore를 호출합니다.

export const useLocalStorage = (key: string) => {
  const subscribe = (listener: () => void) => { /.../ };
  const getSnapshot = () => { /.../ };
  
  const store = useSyncExternalStore(subscribe, getSnapshot);

  // ...
};

getSnapshot

getSnapshot은 localStorage에서 key에 해당하는 값을 반환해줍니다.

subscribe

subscribe 함수에서 storage 이벤트 핸들러를 달아줍니다. 여기서 storage 이벤트는 localStorage, sessionStorage와 같은 storage가 업데이트 될 때 발생합니다.

그리고 이벤트 핸들러를 제거하는 함수를 반환해주세요.

export const useLocalStorage = (key: string) => {
  const getSnapshot = () => localStorage.getItem(key)
  
  const subscribe = (listener: () => void) => {
    window.addEventListener("storage", listener);

    return () => {
      window.removeEventListener("storage", listener);
    };
  };
  
  const store = useSyncExternalStore(subscribe, getSnapshot);

  return [store]
};

저희는 localStorage의 key에 해당하는 값인 store에 접근할 수 있게 됐습니다.

const [name] = useLocalStorage("name");

데이터가 객체인 경우

const [user] = useLocalStorage("user");

user.name
user.age

만약 구독하고 있는 store가 객체라서 위처럼 쓰고 싶은 경우에는 어떻게 하면 될까요?

커스텀 훅에서 store의 타입을 제너럴로 받아주면 됩니다.

useSyncExternalStore이 반환하는 store의 타입은 key에 해당하는 데이터가 있을 경우 string, 없을 경우 null이 됩니다.
store가 null인 경우를 대비하여 사용할 initialValue를 인수로 받습니다.

const useLocalStorage = <T,>(key: string, initialValue: T) => {
  // ...

  const store = useSyncExternalStore(subscribe, getSnapshot); // string | null 타입

  const value = useMemo(() => {
    // localStorage의 key에 데이터가 없을 경우
    if (store === null) return initialValue;

    // localStorage의 key에 데이터가 있을 경우
    const parsedValue = JSON.parse(store) as T;

    return parsedValue;
  }, [store, initialValue]);

  return [value] as const;
};

그럼 아래와 같이 사용할 수 있습니다.

데이터를 바꾸고 싶다면?

localStorage.setItem(key, newValue)로 데이터를 변경할 수 있을 것 같은 느낌이 들어요 🤔

const useLocalStorage = <T,>(key: string, initialValue: T) => {
  // ...

  const setValue = (newValue: T) => {
    const newValueStr =
      typeof newValue === "string" ? newValue : JSON.stringify(newValue);

    localStorage.setItem(key, newValueStr);
  };

  return [value, setValue] as const;
};

아래 예시는 이전에 봤던 name이랑 age가 저장된 user 코드에서 age를 1 증가하는 버튼을 추가하였습니다.

근데 아무리 버튼을 눌러도 화면에 age 값이 증가되지 않았습니다. 왜그럴까요?

그 이유는 age는 localStorage.setItem으로 값이 변경되어, 브라우저에서만 변경을 감지하고 React에서 감지하지 못합니다.

React가 age 값 변경을 알아차리기 위해 storage 이벤트를 일으켜 핸들러를 실행해야 합니다. 이를 위해 사용하는 메서드는 dispatchEvent인데요. 이 메서드는 Event를 전달하여, 해당 이벤트에 대해 등록된 핸들러들을 실행해줍니다.

const useLocalStorage = <T,>(key: string, initialValue: T) => {
  // ...

  const setValue = (newValue: T) => {
    const newValueStr =
      typeof newValue === "string" ? newValue : JSON.stringify(newValue);

    localStorage.setItem(key, newValueStr);

    window.dispatchEvent(
      new StorageEvent("storage", {
        key,
        newValue: newValueStr,
      })
    );
  };

  return [value, setValue] as const;
};

이제 +1 버튼을 누르면 age가 렌더링됩니다!

0개의 댓글