useSyncExternalStore를 리액트 프로젝트에 도입하기

useSyncExternalStore를 리액트 프로젝트에 도입하기

useSyncExternalStore를 사용해야 하는 이유, 그리고 프로젝트에서는 어떻게 사용할 수 있을지에 관해 기록했습니다.

·

5 min read

React 17까지

프론트엔드에서는 다크모드 적용여부, 최신 검색어 목록과 같이 웹 사이트를 종료해도 관련 데이터가 남아있도록 하기 위해 로컬 스토리지를 활용할 수 있다. React 18 이전까지는 리액트 컴포넌트나 훅에서 로컬 스토리지에 직접 접근해도 문제가 발생하지 않았다. 로컬 스토리지는 데이터가 키 밸류 형태로 저장된다. 데이터가 업데이트되면 스토리지 이벤트를 발생시켜 같은 키를 공유하는 타 컴포넌트도 데이터를 갱신하도록 해야 한다.

const useLocalStorage = <T extends unknown = unknown>(
  key: string,
  defaultValue?: T
) => {
  const [state, setState] = useState<T | undefined>();

  useEffect(() => {
    const storedItem = window.localStorage.getItem(key);
    if (storedItem !== null && storedItem !== undefined) {
      const parsed = JSON.parse(storedItem) as T;
      setState(parsed);
      return;
    }
    if (defaultValue !== null && defaultValue !== undefined) {
      const newText = JSON.stringify(defaultValue);
      window.localStorage.setItem(key, newText);
      return;
    }
  }, [key, defaultValue]);

  useEffect(() => {
    const handler = function (event: StorageEvent) {
      const storedItem = window.localStorage.getItem(key) as string;
      if (storedItem === null || storedItem === undefined) {
        setState(undefined);
        return;
      }

      const parsed = JSON.parse(storedItem);
      setState(parsed);
    };
    window.addEventListener("storage", handler);

    return () => {
      window.removeEventListener("storage", handler);
    };
  }, [key]);

  const updateState = (newValue: T) => {
    window.localStorage.setItem(key, JSON.stringify(newValue));
    window.dispatchEvent(new StorageEvent("storage", { key, newValue }));

    setState(newValue);
  };

  const deleteState = () => {
    window.localStorage.removeItem(key);
    window.dispatchEvent(new StorageEvent("storage", { key, newValue }));

    setState(undefined);
  };
};

해당 코드가 작동하는 과정은 다음과 같이 정리할 수 있다.

  1. useLocalStorage 리액트 훅이 호출되면 첫 번째 useEffect가 호출된다. 먼저 로컬 스토리지에 키에 대한 데이터가 있는지 확인한다. 데이터가 없으면 매개변수로 넘겨준 defaultValue로 업데이트한다.

  2. updateState 함수는 같은 타입의 새로운 데이터를 받아서 로컬 스토리지에 저장하도록 한다. 여러 컴포넌트에서 해당 Hooks을 호출하는 경우 한 컴포넌트에서 상태가 변경되어도 다른 컴포넌트에서는 상태가 업데이트되었는지 알 수 없다.

  3. window.dispatchEvent 메소드로 스토리지 이벤트를 발생시킨다. 자바스크립트의 이벤트는 최상위 요소로 전파되기 때문에 window 객체에서 이벤트를 처리하도록 한다. 이벤트를 발생시키기 전 로컬 스토리지에 새로운 데이터를 저장한다.

  4. 이벤트 핸들러 함수에서는 로컬 스토리지에서 새로 저장된 데이터를 가져와 setState 함수로 상태를 업데이트한다.

리액트 18 이전까지는 컴포넌트를 동기적으로 렌더링한다. UI 코드가 작성된 순서대로 실행된다. 리액트 18부터는 동시성(Concurrent) 기능이 구현되었다. 리액트 라이프사이클을 따르지 않는, 외부에 있는 데이터에 접근하게 되면 일관되지 않은 데이터가 렌더링되는 문제점이 있다고 한다.

아직 프로젝트를 진행하면서 동시성 렌더링에 대해 문제를 맞닥뜨린 적은 없지만 미리 대비하는 게 좋을 것 같아서 이슈에 대해서 더 찾아봤다.

import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";

const onSubscribe = (onStoreChange: () => unknown) => {
  const onStorage = function () {
    onStoreChange();
  };
  window.addEventListener("storage", onStorage);

  return () => {
    window.removeEventListener("storage", onStorage);
  };
};

const onServerSnapshot = () => {
  return null;
};

const updateLocalStorage = <K extends string, V>(key: K, value: V) => {
  const formatted = JSON.stringify(value);
  window.localStorage.setItem(key, formatted);
  window.dispatchEvent(
    new StorageEvent("storage", { key, newValue: formatted })
  );
};

const useLocalStorage = <K extends string, V>(key: K, initialValue?: V) => {
  const onSnapshot = useCallback(() => {
    return window.localStorage.getItem(key);
  }, [key]);

  const store = useSyncExternalStore(onSubscribe, onSnapshot, onServerSnapshot);

  const setState = useCallback(
    (action: V | ((next: V) => V)) => {
      if (!(action instanceof Function)) {
        updateLocalStorage(key, action);
        return;
      }
      let parsed = null as V | null;
      try {
        parsed = store ? JSON.parse(store) : null;
      } catch (error) {
        console.warn(error);
      }
      if (parsed !== null) {
        const nextValue = action(parsed);
        updateLocalStorage(key, nextValue);
      }
    },
    [key, store]
  );

  const removeState = useCallback(() => {
    window.localStorage.removeItem(key);
  }, [key]);

  useEffect(() => {
    if (
      window.localStorage.getItem(key) === null &&
      typeof initialValue !== "undefined"
    ) {
      updateLocalStorage(key, initialValue);
    }
  }, [key, initialValue]);

  const parsed = useMemo(() => {
    try {
      if (store) {
        return JSON.parse(store) as V;
      }
    } catch (error) {
      console.warn(error);
      return undefined;
    }
  }, [store]);

  return { state: parsed, setState, removeState };
};

export default useLocalStorage;

새로 작성한 useLocalStoragekey 문자열과 defaultValue를 매개변수로 받는다. key를 받는 이유는 로컬 스토리지에서 다른 데이터와 구분하기 위함이다.

useSyncExternalStore Hooks은 세 가지 함수를 매개변수로 받는다.

  • onSubscribe 함수는 외부 데이터 스토어를 구독하고 구독을 해제하는 함수를 반환한다. 매개변수로 받는 콜백 함수는 스토어가 변경되면 반드시 호출되어야 한다.

  • onSnapshot 함수는 외부 스토어가 변경되었을 때 데이터를 리턴할 수 있다.

  • onServerSnapshot 함수는 리액트 프로젝트가 서버 사이드 렌더링이 적용되어 있고, 웹 페이지가 서버에서 렌더링될 때 데이터를 리턴하기 위해 선언해준다. 서버 렌더링 및 Hydration 진행 중일 때 이 함수가 반환하는 값을 사용하게 된다.

onServerSnapshot 함수가 없으면 여러가지 문제점이 발생할 수 있다.

  • useSyncExternalStore가 브라우저 API와 연동되어 있으면 서버에서 페이지를 렌더링할 때 데이터를 가져올 수 없다.

  • 브라우저 API가 아닌 제 3자 데이터 스토어와 연결되어 있으면 서버에서 렌더링한 페이지와 브라우저에서 렌더링한 페이지가 각각 맞지 않을 수 있다.

로컬 스토리지는 브라우저에서만 사용 가능하기 때문에 onServerSnapshot은 항상 null을 리턴하게 했다.

로컬 스토리지에 데이터가 변경되었을 때

  1. 스토리지 이벤트를 발생시킨다.

  2. onSubscribe 함수에는 스토리지 이벤트 리스너가 등록되어있다. 스토리지 이벤트가 발생하면 onStoreChange 함수가 호출된다

  3. onSnapshot이 로컬 스토리지에서 새로운 데이터를 반환한다.

  4. 로컬 스토리지는 문자열만 저장 가능하기 때문에 필요한 형태로 변환하는 과정이 필요하다.

onSnapshot은 가급적 새로운 객체를 생성하는 형태의 데이터를 반환하지 않는게 좋다.

const onSnapshot = () => {
  return {
    comments: customStore.comments,
  };
};

리액트는 현재 onSnapshot이 반환한 데이터를 마지막으로 반환한 데이터와 비교하는 과정을 거치는데, 새로운 객체를 생성해서 반환하게 되면 페이지가 끊임없이 렌더링되는 문제점이 발생한다.

useLocalStorage에서는 useSyncExternalStore가 문자열 형태의 데이터를 리턴하게 하고 useMemo에서 객체로 변환 후 반환하도록 했다.

SSR 환경에서 활용하기

페이지 내 컴포넌트를 조건부 렌더링할 때 해당 페이지가 서버 또는 클라이언트에서 렌더링됐는지 파악할 수 있다.

  • onSnapshot은 항상 true를 리턴한다.

  • onServerSnapshot은 항상 false를 리턴한다.

Hook이 반환하는 값이 true인지, false인지 따라 브라우저에서 Hydration이 진행되었는지 파악할 수 있다.

import { useSyncExternalStore } from "react";

function onSubscribe() {
  return () => {};
}

function onSnapshot() {
  return true;
}

function onServerSnapshot() {
  return false;
}

const useIsHydrated = () => {
  return useSyncExternalStore(onSubscribe, onSnapshot, onServerSnapshot);
};

export default useIsHydrated;

결론

useSyncExternalStore 리액트 훅으로 컴포넌트 외부에 있는 데이터들과 안전하게 동기화할 수 있다.