Skip to main content

Command Palette

Search for a command to run...

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

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

Updated
5 min read
useSyncExternalStore를 리액트 프로젝트에 도입하기

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 리액트 훅으로 컴포넌트 외부에 있는 데이터들과 안전하게 동기화할 수 있다.

More from this blog

AI 에이전트에서 작업 맥락을 관리하기 위해 사용한 방법

AI 에이전트의 불편함 Codex CLI와 같은 AI 코딩 에이전트를 통해서 프로젝트 개발 속도를 높일 수 있다. 그리고 혼자 작업했을 때는 놓칠 수 있는 부분도 발생할 수 있는데 AI 에이전트를 통해 보완할 수 있었다. 컴포넌트에 대해서 접근성 태그 작성 API 호출하는 비동기 코드 작성할 때 에러 처리 그리고 AI 에이전트를 잘 활용하면 불가능하

Mar 8, 202610 min read

지시사항을 분할하여 Codex로 효율적인 작업하기

이슈 Codex와 같은 AI 기반 코드 에이전트는 출력 토큰의 수가 제한되어 있다. 그래서 다양한 작업을 하나의 프롬프트에 전부 몰아서 작성하면 원하는 결과가 나오지 않을 수 있다. Codex와 같은 LLM 기반 서비스는 출력 토큰 수를 초과하게 되면 일부 단계가 누락되거나 특정 요소를 과하게 요약할 수 있고 이는 전체적인 답변 퀄리티를 낮출 수 있기 때문이다. 그래서 다양한 AI 공식문서는 특정 작업을 더 작은 단위로 분할하는 방식을 강조한다...

Nov 3, 20255 min read

Key를 활용하여 컴포넌트 상태를 초기화하기

개요 컴포넌트의 상태가 업데이트되어도 컴포넌트 내부에 배치된 컴포넌트의 상태는 그대로 유지되는 경우가 있었다. 예전에는 Props 전달 및 내부에서 useEffect를 통해서 상태를 초기 상태로 업데이트하도록 했는데 useEffect를 남용하게 되면 상태 변화 추적이 어려워지는 문제가 있었다. 그런데 useEffect 외에도 Key를 통해서 컴포넌트 상태를 초기화할 수 있다는 사실을 알게 되었다. 리스트 렌더링 React 프론트엔드에서 목록 형...

Sep 21, 20253 min read

내가 AGENTS.md를 작성하는 방법

Codex CLI 요즘 프로젝트 개발에 Codex CLI를 활용하는 이유는 다음과 같다. 특정 프로젝트를 작업할 때 Claude Code는 종종 사용량을 초과한 것과 다르게 Codex CLI는 ChatGPT Plus 요금제만으로도 토큰 사용량 초과 걱정 없이 충분하게 활용할 수 있었다. 타 CLI 기반 AI 개발 도구에 비해서 요구사항을 더 정확하게 구현하고 꼭 필요한 작업만 진행하기 때문에 코드를 검토하는 시간을 줄일 수 있다. 반복적인 코드...

Sep 21, 20254 min read
N

Nowon Lee

22 posts

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