1. 외부 스토어란?
React에서는 보통
useState나 useReducer 같은 훅으로 상태를 관리한다. 하지만 모든 상태가 React 내부에 존재하는 것은 아니다.브라우저 API(
window, localStorage 등)나 외부 라이브러리(Zustand, Redux 등)처럼 React 바깥에서 독립적으로 동작하는 상태 저장소를 외부 스토어라고 부른다.즉, 외부 스토어는 React와 별도로 유지·업데이트되지만, React 컴포넌트에서도 안전하게 구독해야 할 필요가 있는 상태를 의미한다.
2. 첫번째 문제
window객체는 대표적인 외부 스토어로 React 내에서도 흔히 사용한다.아래 조건을 보고 React에서 어떻게 구현할 수 있을지 한번 생각해보자.
구현해보자
window.innerWidth값을 텍스트로 표시해야한다.
- 실제로 window size를 변경하면 실시간으로 값이 바뀌어야한다.
*힌트: resize이벤트 활용하기
2.1 state 사용하기
흔하게 생각할 수 있는 방법은 state를 만드는 방법이다.
state를 만들어
window.innerWidth값을 react의 생명주기 내부에서 관리되도록 할 수 있다.export default function Example1() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const handleResize = () => { setWidth(window.innerWidth); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return <div className="mt-20 rounded-md bg-blue-200 px-4 py-2 font-medium">width: {width}</div> }
2.2 Ref 사용하기
다른 방법으로 ref를 만들어 해결할수도 있다.
ref를 사용하면, react에 구속받지 않고 dom을 업데이터 하여 react와 별개로 업데이트 되도록 할 수 있다.
export default function Example2() { const widthRef = useRef<HTMLDivElement>(null); useEffect(() => { const handleResize = () => { if (widthRef.current) { widthRef.current.innerText = `width: ${window.innerWidth}`; } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return ( <div ref={widthRef} className="mt-20 rounded-md bg-yellow-200 px-4 py-2 font-medium"> {typeof window !== 'undefined' && `width: ${window.innerWidth}`} </div> ); }
2.3 문제점
하지만 위 두 방법에는 아래와 같은 이유로 문제가 있다.
setState를 사용하면, 강제로 동일한 값을 React에서 별도로 관리해야 하는 문제가 생긴다.
→ 이 과정에서 실제 값과 React 내부 값이 어긋나 값 불일치가 발생할 수 있다.
- ref를 사용하면 DOM을 업데이트하기 위해 React의 렌더링 패턴을 무시하게 된다.
→ React의 선언적 렌더링 흐름과 실제 DOM 상태가 불일치하게 된다.
- 특히 React 18 이상에서는 Concurrent Rendering(동시성 렌더링) 이 도입되면서, 외부 스토어 값이 변하는 순간 일부 컴포넌트는 옛 값, 다른 컴포넌트는 새 값을 읽어오는 상황이 발생할 수 있다.
→ 이런 일관성 없는 UI 불일치 현상을 tearing 문제라고 한다.

*외부 스토어가 변화할때 tearing문제가 발생한 상태의 컴포넌트 랜더 트리를 표현한 모습
이러한 문제들로 인해 단순히
setState나 ref를 사용하는 방식으로는 React와 외부 스토어 사이의 상태를 안전하게 동기화하기 어렵다.3. useSyncExternalStore
React 18부터는 외부 스토어를 안전하게 동기화하고 구독할 수 있는 훅
useSyncExternalStore가 도입되었다.const state = useSyncExternalStore( subscribe, // (cb) => unsubscribe 함수 반환 getSnapshot, // 클라이언트에서 현재 상태 가져오기 getServerSnapshot? // (선택) 서버 렌더링 시 상태 가져오기 )
이 훅을 사용하면 React의 렌더링 흐름과 외부 스토어의 업데이트를 일관성 있게 연결할 수 있다.
subscribe: 외부 스토어가 변경될 때 실행할 콜백을 등록하고, 해제하는 함수
getSnapshot: 현재 스토어의 최신 상태를 반환하는 함수
React는 이 두 함수를 통해 외부 스토어를 React의 상태처럼 안전하게 다룰 수 있게 된다.
3.1 해결
subscribe함수는 resize이벤트를 수신하여 callback을 호출해준다.그리고,
snapshot함수에서 window.innerWidth를 반환하면 resize이벤트가 발생할때마다 fresh한 innerWidth값을 가져올 수 있게 된다.import { useSyncExternalStore } from 'react'; function subscribe(callback: () => void) { window.addEventListener('resize', callback); return () => window.removeEventListener('resize', callback); } function getSnapshot() { return window.innerWidth; } export default function Example3() { const width = useSyncExternalStore(subscribe, getSnapshot); return <div className="mt-20 rounded-md bg-green-200 px-4 py-2 font-medium">width: {width}</div> }
4. Zustand
상태관리 라이브러리인 zustand에서도 외부 스토어를 관리할 수 있다.
import { useStore } from 'zustand' import { createStore } from 'zustand/vanilla'; const counterStore = createStore(/* ... */) function Counter() { const count = useStore(counterStore, (s) => s.count) return <div>{count}</div> }
zustand의 useStore는 내부적으로 useSyncExternalStore를 사용하기 때문에, 외부 스토어를 연동해도 React 내부에서 안전하게 상태를 구독할 수 있다.즉, React 상태와 동일한 안정성을 보장한다.
4.1 응용 (Zustand Vanilla Store)
vanilla store는 React와 분리된 순수 zustand 스토어다.외부 객체가 이벤트를 발생시키고, 그 이벤트를 받아 zustand 상태를 갱신하는 구조라면
zustand/vanilla를 사용하는 것이 더 깔끔하다.예를 들어, 브라우저
window 객체의 사이즈를 관리하는 브릿지를 아래와 같이 만들 수 있다.// window-store.ts import { createStore } from 'zustand/vanilla'; type WindowState = { width: number }; export const windowStore = createStore<WindowState>(() => ({ width: typeof window !== 'undefined' ? window.innerWidth : 0, })); // 클라이언트 환경일 때만 전역적으로 리스너 등록 if (typeof window !== 'undefined') { const handler = () => { windowStore.setState({ width: window.innerWidth }); }; window.addEventListener('resize', handler); // 초기 동기화 handler(); }
React 컴포넌트에서는
useStore 훅을 통해 vanilla store를 안전하게 구독할 수 있다:import { useStore } from 'zustand/react'; export default function Example4() { // zustand store를 React 컴포넌트에서 안전하게 구독 const width = useStore(windowStore, (s) => s.width); return ( <div className="mt-20 rounded-md bg-purple-200 px-4 py-2 font-medium">width: {width}</div> ); }
vanilla store는 React 외부에서도 접근할 수 있으므로, 이벤트 기반 외부 객체(window,WebSocket,EventSource, 커스텀 ticker 등)와 연동하기에 적합하다.
- React 컴포넌트에서는
useStore로 구독만 하면 되기 때문에 외부 로직과 UI 로직이 명확히 분리된다.
- 하나의 store를 React 안팎에서 동시에 활용할 수 있어, 외부 이벤트 → store 갱신 → 컴포넌트 자동 리렌더링의 흐름을 자연스럽게 구성할 수 있다.
5. 두번째 문제
window.innerWidth는 단일 숫자 값을 다루는 간단한 외부 스토어였다.하지만 실제 애플리케이션에서는 하나의 값만이 아니라 여러 필드가 동시에 독립적으로 변하는 복잡한 외부 객체를 다뤄야하는 상황이라면 어떨까?
이를 연습하기 위해
prime-ticker라는 예제를 살펴보자.prime-ticker는 React와 완전히 독립적으로 동작하며, React가 개입하지 않아도 자체적으로 값이 바뀌고 이벤트를 발생시키는 객체이다.즉, React 입장에서는 외부에서 계속 "변화하는 데이터 소스"를 안전하게 구독해야 하는 상황을 흉내낸 것이다.
prime-ticker의 명세
a, b, c, d, e다섯 개의 필드를 가진다.
- 각각의 필드는 서로 다른 주기(2초, 3초, 5초, 7초, 11초)마다
1씩 증가한다.
- 필드 값이 바뀔 때마다 해당 필드 전용 이벤트를 발생시킨다.
- 각 필드는 getter/setter 메소드를 통해 접근 및 수정할 수 있다.
구현해보자
- React 컴포넌트에서 다섯 필드를 실시간으로 표시해야 한다.
- 각 필드를 초기화할 수 있는 수단을 제공해야 한다.
prime-ticker.ts
entrolEC
5.1 구현
아래는 zustand를 사용한 구현이다.
- 컴포넌트
prime-ticker2.tsx
entrolEC
- 브릿지
prime-ticker-store.ts
entrolEC
각 필드 버튼을 클릭하면 아래와 같은 흐름이 나타나게 된다.
- 버튼을 눌러 필드를 초기화 한 경우
- React UI(버튼 클릭)
- onClick → primeTicker.set(…)
- primeTicker 내부 상태 변경
- primeTicker 이벤트 발생
- (브릿지) zustand.setState
- React 컴포넌트는 zustand만 구독 → 리렌더링 발생
6. 결론
외부 스토어를 React에서 다룰 때는
useSyncExternalStore로 안전하게 구독하는 것이 적절하다.규모가 크거나 복잡하다면 zustand 같은 상태 관리 라이브러리를 이용해 외부 스토어를 통합 관리할 수도 있다.
external-state-management
entrolEC • Updated Sep 21, 2025
