React 외부의 상태
React 컴포넌트는 자기만의 상태 (useState) 와 Context API로 대부분의 상태를 다룰 수 있습니다. 하지만 어떤 상태는 React 밖에 존재해야 합니다.
- 외부 상태 관리 라이브러리 , Redux, Zustand 같은 라이브러리는 모듈 스코프에 스토어를 둡니다
- 브라우저 API ,
window.location,localStorage,navigator.onLine같은 값은 React가 모르는 사이에 변경될 수 있습니다 - WebSocket, EventSource , 외부 데이터 소스가 비동기로 값을 푸시합니다
이런 외부 상태를 React 컴포넌트에서 사용하려면 구독 메커니즘이 필요합니다. 외부 값이 바뀌면 컴포넌트가 알아차리고 다시 렌더링되어야 합니다.
React 17까지는 이 패턴이 단순했습니다. useEffect로 구독하고, useState로 값을 보관하면 됐습니다.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const update = () => setIsOnline(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return isOnline;
}작동은 합니다. 하지만 React 18의 Concurrent Rendering에서 이 패턴은 미묘한 버그를 일으킬 수 있습니다.
Tearing, 화면이 찢어지는 버그
Tearing 은 같은 렌더링 사이클 안에서 컴포넌트들이 외부 상태의 서로 다른 값 을 읽는 현상입니다. 결과적으로 화면의 일부는 이전 값을, 다른 일부는 새 값을 보여줍니다, 마치 화면이 찢어진 것처럼.
Concurrent Rendering 이전에는 tearing이 발생할 수 없었습니다. 렌더링이 한번 시작되면 끝까지 동기적으로 진행되었기 때문에, 렌더링 도중에 외부 값이 바뀌어도 그 변경은 다음 렌더링에 반영되었습니다.
Concurrent Rendering에서는 이 보장이 깨집니다.
시간 →
T1: 렌더링 시작
T2: ComponentA가 store.value를 읽음 → 5
T3: (React가 렌더링을 일시 중단, 더 긴급한 작업 처리)
T4: (이 사이에 외부에서 store.value가 6으로 변경됨)
T5: (React가 렌더링 재개)
T6: ComponentB가 store.value를 읽음 → 6
T7: 렌더링 완료
→ 같은 렌더링인데 A는 5, B는 6을 보여줌이것이 tearing입니다. 사용자에게는 이렇게 보입니다, 헤더의 카운터는 5, 본문의 같은 카운터는 6. 같은 데이터인데 일치하지 않는 화면.
왜 useState로는 부족한가
useEffect + useState 패턴은 tearing에 취약합니다. 이유를 보면 명확합니다.
- 외부 값이 변경되면
useEffect안의 핸들러가 호출됨 setState로 React 상태를 업데이트- React가 리렌더링을 스케줄함
- 이 사이에 새로운 렌더링이 이미 시작될 수 있음
- 그 렌더링은 이전 React 상태를 사용하므로 일관되지 않을 수 있음
문제의 핵심은 외부 값과 React 상태 사이에 틈 이 있다는 것입니다. 외부 값이 바뀌고 React 상태가 동기화되기 전 사이에 렌더링이 일어날 수 있습니다.
useSyncExternalStore, 해결책
React 18은 이 문제를 해결하기 위해 useSyncExternalStore를 도입했습니다. 이름이 길지만 의미는 명확합니다, 외부 스토어를 동기적으로 사용하는 Hook 입니다.
import { useSyncExternalStore } from 'react';
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 구독 함수
getSnapshot, // 현재 값을 반환하는 함수
getServerSnapshot // (선택) SSR 시 사용할 값
);
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // 서버에서는 항상 true로 가정
}세 가지 인자를 받습니다.
subscribe, 외부 값이 변경될 때 호출될 콜백을 등록합니다. 등록 해제 함수를 반환해야 합니다getSnapshot, 현재 값을 반환합니다. React는 이 값을 비교해 변경 여부를 판단합니다getServerSnapshot, SSR 시 사용할 값입니다. 서버에서는subscribe가 동작하지 않으므로 별도의 값이 필요합니다
useSyncExternalStore가 tearing을 막는 방법
핵심은 React가 렌더링 중에도 getSnapshot을 다시 호출 한다는 점입니다.
- 렌더링 시작
useSyncExternalStore가getSnapshot을 호출하여 값 A를 받음- 다른 컴포넌트가 또
getSnapshot을 호출 - 두 값이 다르면 React는 현재 렌더링을 버리고 동기적으로 다시 시작 합니다
- 모든 컴포넌트가 같은 스냅샷을 볼 때까지 반복
이렇게 React는 렌더링 사이클 안에서 모든 컴포넌트가 같은 외부 값을 읽도록 보장합니다. tearing이 원천적으로 차단됩니다.
getSnapshot의 안정성이 중요하다
getSnapshot은 같은 입력에 대해 같은 결과를 반환해야 합니다. 매번 새 객체를 만들면 React가 변경되었다고 잘못 판단하여 무한 루프에 빠질 수 있습니다.
// ❌ 나쁜 예, 매번 새 객체 생성
function getSnapshot() {
return { online: navigator.onLine };
}
// ✅ 좋은 예, 원시 값 반환
function getSnapshot() {
return navigator.onLine;
}
// ✅ 객체가 필요하다면 캐싱
let cachedSnapshot = null;
function getSnapshot() {
const current = navigator.onLine;
if (!cachedSnapshot || cachedSnapshot.online !== current) {
cachedSnapshot = { online: current };
}
return cachedSnapshot;
}외부 상태 관리 라이브러리에서의 활용
useSyncExternalStore의 가장 큰 혜택을 받은 것은 외부 상태 관리 라이브러리들입니다. Zustand, Redux, Valtio 등이 모두 이 API를 채택하면서 React 18의 Concurrent Rendering과 안전하게 동작하게 되었습니다.
Zustand의 예
Zustand는 v4부터 useSyncExternalStore를 채택했고 (React 17 이하에서는 use-sync-external-store shim 사용), v5 (2024) 에서 shim을 걷어내고 React 18 이상의 네이티브 API로 완전히 전환했습니다.
// Zustand 내부 구현 (단순화)
function useStore(selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState())
);
}사용자 코드는 이전과 거의 동일합니다. 하지만 내부에서는 useSyncExternalStore를 통해 tearing을 방지합니다.
Redux의 예
Redux도 react-redux v8부터 useSyncExternalStore를 사용합니다.
// react-redux의 useSelector (개념적 단순화)
function useSelector(selector) {
const store = useStore();
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState())
);
}직접 작성하는 경우
직접 외부 값을 다룰 때 useSyncExternalStore를 사용하는 것이 베스트 프랙티스입니다. 몇 가지 흔한 케이스를 보겠습니다.
미디어 쿼리 구독
function useMediaQuery(query) {
const subscribe = useCallback((callback) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}, [query]);
const getSnapshot = () => window.matchMedia(query).matches;
const getServerSnapshot = () => false;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
// 사용
const isDark = useMediaQuery('(prefers-color-scheme: dark)');localStorage 구독
function useLocalStorage(key) {
const subscribe = useCallback((callback) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
}, []);
const getSnapshot = () => localStorage.getItem(key);
const getServerSnapshot = () => null;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}storage 이벤트는 다른 탭에서의 변경만 감지합니다. 같은 탭의 변경을 감지하려면 추가 작업이 필요하지만, 핵심 패턴은 동일합니다.
베스트 프랙티스 정리
1. 외부 값에는 무조건 useSyncExternalStore. useState + useEffect 패턴은 React 18에서 tearing 위험이 있습니다. 새로 작성하는 코드는 useSyncExternalStore를 사용합니다.
2. getSnapshot은 안정적이어야 한다. 같은 외부 상태에 대해 같은 참조를 반환해야 합니다. 매번 새 객체를 반환하면 안 됩니다.
3. SSR을 고려한다면 getServerSnapshot을 제공한다. 서버에서는 navigator, window, localStorage 같은 것이 없습니다.
4. 외부 라이브러리를 쓴다면 useSyncExternalStore 채택 여부를 확인한다. Zustand v4+, react-redux v8+, Valtio 등은 이미 채택했습니다. 오래된 라이브러리는 React 18에서 tearing 위험이 있을 수 있습니다.
5. selector 패턴을 사용한다면 셀렉터의 안정성도 확인한다. Zustand에서 객체를 반환하는 selector는 shallow 비교나 useShallow를 사용해야 매번 리렌더링되지 않습니다.
// ❌ 매번 새 객체
const { count, name } = useStore((state) => ({
count: state.count,
name: state.name
}));
// ✅ Zustand v5의 useShallow
import { useShallow } from 'zustand/react/shallow';
const { count, name } = useStore(
useShallow((state) => ({ count: state.count, name: state.name }))
);다음 단계
다음 글에서는 React 19가 도입한 새로운 패러다임인 Actions 를 다룹니다. 폼 제출, mutation, 낙관적 업데이트, 이전에는 수동으로 관리해야 했던 모든 것이 어떻게 React 자체의 기능으로 통합되었는지를 정리합니다.