다크 모드 토글을 구현하다 보면 한 번쯤 마주치는 패턴이 있다. 테마 값을 localStorage에 저장하고, document.documentElement의 data-theme 속성에 반영하고, React에서는 그 값을 읽어서 UI에 표시하는 식이다. 이때 React state와 DOM attribute, 두 군데에 같은 값이 존재하게 되는데 이 둘을 어떻게 동기화할 것인가가 관건이다.
기존에는 useEffect로 마운트 시점에 DOM 값을 읽어와 useState에 복사하는 방식을 썼다. 동작은 했지만 React lint가 경고를 띄웠고, 첫 렌더 직후 한 번 더 리렌더링이 발생하는 구조였다. useSyncExternalStore로 바꾸고 나니 훨씬 깔끔해졌기에 정리해 둔다.
useSyncExternalStore가 무엇인가useSyncExternalStore는 React가 React 밖에 있는 상태를 안전하게 읽고 구독하기 위한 훅이다.
이번 케이스에서는 테마 상태의 실제 원천이 React state가 아니라 DOM의 document.documentElement.dataset/data-theme이다. 그래서 React 입장에서는 data-theme이 외부 저장소가 된다.
기본 형태는 다음과 같다.
const value = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
각 인자의 의미는 이렇다.
subscribe(listener)listener()를 호출해야 한다.getSnapshot()getServerSnapshot()지금 ThemeProvider에서는 대략 이런 구조를 가진다.
const theme = useSyncExternalStore(
subscribeTheme,
getThemeSnapshot,
getServerThemeSnapshot,
);
즉 React는 다음과 같이 동작한다.
getThemeSnapshot()으로 현재 data-theme를 읽는다.subscribeTheme()으로 테마 변경 알림을 구독한다.setDocumentTheme()이 data-theme를 바꾸고 listener들을 호출한다.원래 코드의 문제는 이거였다.
useEffect(() => {
const t = document.documentElement.getAttribute("data-theme") || "light";
setTheme(t);
}, []);
첫 렌더는 "light"로 하고, effect가 실행된 직후 다시 setTheme()을 호출해서 한 번 더 렌더링했다.
React lint가 이 패턴을 "effect 안에서 동기 setState 호출"로 경고한 것이다.
useSyncExternalStore를 쓰면 data-theme를 처음부터 외부 상태로 취급하므로, effect로 React state에 복사하지 않아도 된다.
쓸 만한 상황은 이렇다.
localStorage, browser API, 전역 store처럼 React 밖 상태를 구독할 때반대로 굳이 안 써도 되는 경우도 있다.
이런 건 그냥 useState가 맞다.
이번 테마 코드는 실제 상태가 DOM attribute에 있고 React는 그걸 표시만 해야 하는 구조이기 때문에 useSyncExternalStore가 맞는 선택이었다.
훅 이름이 길고 인자도 셋이라 처음 보면 부담스럽지만, 결국 하는 일은 단순하다. "어떻게 읽을지, 어떻게 변화를 구독할지, 서버에서는 뭘 보여줄지" 세 가지를 React에게 알려주는 것이다. 외부 상태를 React state로 굳이 복사하지 않고도 동기화할 수 있다는 점에서, 비슷한 패턴을 만날 때마다 먼저 떠올려 볼 만한 도구다.