
React
Tearing부터 Zustand까지 — useSyncExternalStore 완전 해부
React Concurrent Rendering에서 발생하는 Tearing 문제를 이해하고, useSyncExternalStore가 이를 어떻게 해결하는지, 그리고 Zustand와 TanStack Query가 내부적으로 이 훅을 어떻게 활용하는지 깊이 파헤칩니다.
한 번의 결과보다, 그 결과에 도달한 과정을 기록합니다.
같은 화면에서 어떤 컴포넌트는 "좋아요 3개"를, 바로 옆 컴포넌트는 "좋아요 4개"를 보여주고 있다면? 이것은 버그가 아니라, React Concurrent Rendering이 만들어낸 구조적 문제 — Tearing이다.
이 글에서는 Tearing이 왜 발생하는지부터, React가 이를 해결하기 위해 설계한 useSyncExternalStore의 내부 구현까지 해부한다. 그리고 Zustand와 TanStack Query가 이 훅을 어떻게 자신들의 아키텍처에 녹여냈는지, 실전에서 Tearing을 재현하고 디버깅하는 방법, 그리고 React 19에서 달라지는 것들까지 다룬다.
useSyncExternalStore는 이 문제를 해결하기 위해 React 18에서 도입된 공식 훅이다.useSyncExternalStore를 사용하여 외부 스토어와 React 렌더링의 정합성을 보장한다.React 17 이하의 동기적 렌더링에서는 컴포넌트 트리를 한 번에 쭉 렌더링했다. 렌더링 도중에 외부 상태가 바뀌어도, 이미 시작된 렌더는 중단 없이 완료되므로 모든 컴포넌트가 동일한 스냅샷을 읽었다.
// 동기적 렌더링: A → B → C 순서로 한 번에 완료
// 외부 상태가 중간에 바뀌어도, 이미 읽은 값으로 렌더 완료
[Render Start] → A(v1) → B(v1) → C(v1) → [Commit]React 18의 Concurrent Rendering은 렌더링 작업을 중단(pause), 재개(resume), 폐기(abandon) 할 수 있다. 이는 사용자 인터랙션의 반응성을 극적으로 개선하지만, 한 가지 전제가 깨진다.
렌더링 도중 외부 상태가 변경될 수 있다.
// Concurrent 렌더링: 렌더 도중 외부 상태가 v1 → v2로 변경
[Render Start] → A(v1) → B(v1) → ⏸️ (중단)
↓ 외부 상태 변경: v1 → v2
[Resume] → C(v2) → [Commit]
// 결과: A, B는 v1을, C는 v2를 보여줌 → Tearing!이것이 Tearing이다. UI가 말 그대로 "찢어져서" 같은 데이터인데 서로 다른 값을 표시하는 현상이다.
아래 시퀀스 다이어그램으로 Tearing이 발생하는 전체 흐름을 살펴보자:
sequenceDiagram
participant R as React Scheduler
participant A as Component A
participant B as Component B
participant C as Component C
participant S as External Store
Note over S: state = v1
R->>A: render
A->>S: getSnapshot() = v1
R->>B: render
B->>S: getSnapshot() = v1
Note over R: yield (time slice)
Note over S: state = v2 (외부 변경!)
R->>C: render
C->>S: getSnapshot() = v2
R->>R: commit
Note over A,C: A,B = v1 / C = v2 = Tearing!useState, useReducer 같은 React 내부 상태는 React가 직접 관리한다. React는 렌더링 중에 상태 변경이 감지되면 해당 렌더를 폐기하고 새로운 스냅샷으로 다시 시작한다. 하지만 외부 스토어(Redux, Zustand, 전역 변수, DOM API 등)는 React의 관리 밖에 있어서 이런 보호를 받지 못한다.
const snapshot = useSyncExternalStore(
subscribe, // (callback) => unsubscribe
getSnapshot, // () => snapshot (client)
getServerSnapshot? // () => snapshot (SSR, optional)
);세 개의 인자를 받는다:
핵심 메커니즘은 간단하다:
getSnapshot()을 호출하여 스냅샷을 캡처한다.최악의 경우 렌더링이 더 오래 걸릴 수 있지만, 사용자는 항상 일관된 UI를 보게 된다.
// 외부 스토어 정의
const counterStore = {
value: 0,
listeners: new Set<() => void>(),
subscribe(callback: () => void) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
},
getSnapshot() {
return this.value;
},
increment() {
this.value++;
this.listeners.forEach(listener => listener());
}
};
// React 컴포넌트에서 사용
function Counter() {
const count = useSyncExternalStore(
counterStore.subscribe.bind(counterStore),
counterStore.getSnapshot.bind(counterStore)
);
return <div>{count}</div>;
}getSnapshot이 매 호출마다 새로운 객체를 생성하면 무한 리렌더링에 빠진다. React는 Object.is로 이전 스냅샷과 비교하기 때문이다.
// ❌ 매번 새 객체를 생성 → 무한 리렌더
getSnapshot: () => ({ count: store.count })
// ✅ 불변 참조를 반환
getSnapshot: () => store.state // store.state 자체가 immutable snapshotuseSyncExternalStore의 API는 단순하지만, 내부에서는 useEffect, useLayoutEffect, useState를 교묘하게 조합하여 두 가지 핵심 문제를 해결한다:
getSnapshot() 호출과 subscribe() 등록 사이에 외부 상태가 변경될 수 있다React 소스 코드(shim 버전)를 기반으로 내부 동작을 살펴보자.
const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});첫 번째 놀라운 점은 useState를 실제 상태 관리가 아닌 용도로 사용한다는 것이다.
inst는 mutable 객체로, 현재 스냅샷(value)과 getSnapshot 함수를 보관한다useState의 상태 슬롯을 useRef 대신 활용하여 메모리를 절약한다forceUpdate({inst})를 호출하면 새 객체가 Object.is 비교에 실패하므로 강제 리렌더가 발생한다// forceUpdate의 동작 원리
forceUpdate({inst}) // 매번 새 객체 → Object.is 실패 → 리렌더 트리거useLayoutEffect(() => {
// 1. inst를 최신 값으로 갱신
inst.value = value;
inst.getSnapshot = getSnapshot;
// 2. 렌더와 커밋 사이에 변경이 있었는지 확인
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
}, [subscribe, value, getSnapshot]);useLayoutEffect는 DOM 업데이트 직후, 브라우저 페인트 이전에 동기적으로 실행된다. 여기서 하는 일은:
inst.value와 inst.getSnapshot을 최신 값으로 업데이트한다. 이후 useEffect에서 이 값을 참조하므로 항상 최신이어야 한다.useLayoutEffect가 외부 스토어를 변경했을 수 있다. 이를 감지하면 즉시 리렌더를 스케줄링한다.useEffect(() => {
// 1. 구독 직전에 한 번 더 체크
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
// 2. 스토어 구독
const handleStoreChange = () => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
};
// 3. subscribe 호출, 클린업으로 구독 해제 반환
return subscribe(handleStoreChange);
}, [subscribe]);여기가 가장 중요한 부분이다. useEffect는 브라우저 페인트 이후 비동기적으로 실행되는데, 이 특성 때문에 구독 간극 문제가 발생한다:
[렌더] getSnapshot() → v1
↓ (이 사이에 외부 상태가 v1 → v2로 변경될 수 있음!)
[커밋] DOM 업데이트
↓
[페인트] 브라우저 화면 렌더
↓
[useEffect] subscribe() 실행 ← 여기서야 구독이 시작됨이 간극을 메우기 위해 subscribe() 호출 직전에 checkIfSnapshotChanged(inst)를 한 번 더 수행한다. 만약 그 사이에 스토어가 변경되었다면, 즉시 forceUpdate로 리렌더를 트리거한다.
handleStoreChange는 구독 이후 스토어가 변경될 때마다 호출되는 콜백이다. 여기서도 매번 checkIfSnapshotChanged를 거쳐서 실제로 스냅샷이 바뀌었을 때만 리렌더를 트리거한다.
function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !Object.is(prevValue, nextValue);
} catch (error) {
return true; // 에러 발생 시 안전하게 리렌더
}
}이 함수는 세 곳에서 호출된다:
Object.is를 사용하므로 참조 동일성으로 비교한다. 이것이 앞서 getSnapshot에서 매번 새 객체를 생성하면 안 되는 이유다.
[Initial Mount]
1. getSnapshot() → v1 // 렌더 중 스냅샷 캡처
2. useState({inst: {value: v1}}) // inst 초기화
3. [Commit Phase]
4. useLayoutEffect 실행 // inst 갱신 + 인터리빙 체크
5. [Browser Paint]
6. useEffect 실행 // 간극 체크 + subscribe() 호출
[Store Change: v1 → v2]
7. handleStoreChange() 호출
8. checkIfSnapshotChanged → true
9. forceUpdate({inst}) // 리렌더 스케줄링
10. [Re-render]
11. getSnapshot() → v2 // 새 스냅샷으로 렌더이 흐름을 다이어그램으로 정리하면:
flowchart TD
A["Render: getSnapshot = v1"] --> B["Commit Phase"]
B --> C["useLayoutEffect"]
C --> D{"snapshot 변경?"}
D -->|Yes| E["forceUpdate = Re-render"]
D -->|No| F["Browser Paint"]
F --> G["useEffect"]
G --> H{"snapshot 변경?"}
H -->|Yes| E
H -->|No| I["subscribe 호출"]
I --> J["Listening"]
J --> K{"Store 변경"}
K --> L{"snapshot 변경?"}
L -->|Yes| E
L -->|No| J
style E fill:#ff6b6b,color:#fff
style J fill:#51cf66,color:#fff핵심은 세 곳의 checkIfSnapshotChanged가 모든 간극을 메운다는 것이다. useLayoutEffect는 커밋 직후, useEffect는 구독 직전, 그리고 handleStoreChange는 구독 이후의 변경을 각각 감지한다.
위 코드는 shim(React 16/17 호환) 버전이다. React 18의 네이티브 구현(mountSyncExternalStore)에는 한 가지 추가 메커니즘이 있다:
// React 18 네이티브 — mountSyncExternalStore 내부
if (!includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}Consistency Check: Concurrent 모드에서 렌더링할 때, React는 커밋 직전에 모든 외부 스토어의 스냅샷을 한 번 더 검증하는 체크를 스케줄링한다. 만약 렌더 도중 스토어가 변경되었다면 이를 감지한다.
function forceStoreRerender(fiber) {
const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
// ^^^^^^^^
// 핵심: SyncLane으로 강제!
}
}SyncLane 강제: 외부 스토어 변경으로 인한 리렌더는 항상 SyncLane(blocking lane)으로 스케줄링된다. Concurrent 모드에서 tearing이 발생하는 이유는 렌더가 중단/재개되기 때문인데, SyncLane은 중단 없이 동기적으로 실행되므로 다음 렌더에서는 tearing이 원천적으로 불가능하다.
// 정리: 두 단계 보호
1단계: Consistency Check → 커밋 직전에 스냅샷 불일치 감지
2단계: SyncLane 강제 → 재렌더를 동기적으로 실행하여 tearing 방지useSyncExternalStore를 사용하는 거의 모든 외부 스토어는 내부적으로 Set을 사용하여 리스너를 관리한다. 이 패턴은 useSyncExternalStore의 subscribe 계약과 완벽하게 맞물린다.
// subscribe의 계약:
// 1. callback을 등록하고
// 2. 구독 해제 함수를 반환한다
type Subscribe = (callback: () => void) => () => void;Set이 이 패턴에 최적인 이유:
const listeners = new Set<() => void>();
// subscribe 구현
function subscribe(callback: () => void) {
listeners.add(callback); // O(1) 등록
return () => {
listeners.delete(callback); // O(1) 해제, 참조 동일성으로 정확히 해당 콜백만 제거
};
}
// 상태 변경 시 모든 리스너에게 통지
function notify() {
listeners.forEach(listener => listener()); // 삽입 순서대로 호출
}왜 Array가 아닌 Set인가?
| Set | Array | |
|---|---|---|
| 등록 (add) | O(1) | O(1) (push) |
| 해제 (delete) | O(1) | O(n) (indexOf + splice) |
| 중복 방지 | 자동 (같은 참조는 1번만 등록) | 수동으로 체크 필요 |
| 순회 (forEach) | 삽입 순서 보장 | 인덱스 순서 |
React의 useEffect 클린업 → 재구독 사이클에서 같은 컴포넌트가 subscribe를 여러 번 호출할 수 있다. Set은 중복 등록을 자동으로 방지하고, delete가 O(1)이므로 구독 해제가 빠르다.
실제로 Zustand, Redux, Valtio 모두 Set<() => void>로 리스너를 관리한다:
// Zustand 실제 코드 (단순화)
const createStore = (createState) => {
let state;
const listeners = new Set(); // ← Set 사용
const setState = (partial) => {
const nextState = typeof partial === 'function'
? partial(state) : partial;
if (!Object.is(nextState, state)) {
const previousState = state;
state = Object.assign({}, state, nextState);
// Set.forEach로 모든 구독자에게 통지
listeners.forEach((listener) => listener(state, previousState));
}
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
// ...
};Set.forEach 순회 도중 새 요소가 추가되면 해당 요소도 순회된다. 리스너가 다른 리스너를 등록/해제하는 경우, 순회 중 Set을 복사하는 방어 코드가 필요할 수 있다:const currentListeners = new Set(listeners);
currentListeners.forEach(listener => listener());
Zustand는 v4부터 useSyncExternalStore를 도입했다. 내부 구현을 살펴보면 구조가 놀라울 정도로 단순하다.
Zustand의 스토어는 크게 두 레이어로 나뉜다:
createStore): React에 의존하지 않는 순수 상태 저장소create): useSyncExternalStore로 Vanilla Store를 React에 연결// 1. Vanilla Store — React 의존성 없음
function createStore(createState) {
let state;
const listeners = new Set();
const setState = (partial) => {
const nextState = typeof partial === 'function'
? partial(state)
: partial;
if (!Object.is(nextState, state)) {
state = typeof nextState === 'object'
? Object.assign({}, state, nextState)
: nextState;
listeners.forEach(listener => listener(state));
}
};
const getState = () => state;
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
// 초기 상태 생성
state = createState(setState, getState);
return { setState, getState, subscribe };
}// 2. React Binding — useSyncExternalStore로 연결
function useStore(api, selector = (state) => state) {
const slice = useSyncExternalStore(
api.subscribe,
() => selector(api.getState()), // client snapshot
() => selector(api.getState()) // server snapshot
);
return slice;
}Zustand에서 useStore(store, (state) => state.count) 형태로 셀렉터를 사용하는 이유가 여기서 명확해진다.
useSyncExternalStore의 getSnapshot이 반환하는 값이 이전과 Object.is로 동일하면 리렌더링이 발생하지 않는다. 셀렉터를 통해 필요한 슬라이스만 추출하면, 관련 없는 상태 변경에 대해 불필요한 리렌더링을 방지할 수 있다.
const useBearStore = create((set) => ({
bears: 0,
fish: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}));
// ✅ bears가 바뀔 때만 리렌더
const bears = useBearStore((state) => state.bears);
// ❌ fish가 바뀌어도 리렌더 (전체 state 객체가 매번 새로 생성)
const state = useBearStore();useReducer + useEffect 패턴으로 구독했는데, 이 방식은 Concurrent Rendering에서 tearing 위험이 있었다. v4에서 useSyncExternalStore로 전환하면서 이 문제가 근본적으로 해결되었다.TanStack Query(구 React Query)도 v5부터 useSyncExternalStore를 내부적으로 사용한다. 하지만 Zustand와는 사용 맥락이 다르다.
Zustand가 클라이언트 상태를 관리한다면, TanStack Query는 서버 상태를 관리한다. 서버 상태의 특징은:
TanStack Query는 3개 레이어로 구성된다:
useSyncExternalStore로 Observer와 React를 연결// TanStack Query 내부 (단순화)
function useBaseQuery(options, QueryObserver) {
const queryClient = useQueryClient();
// Observer 생성 — QueryCache의 특정 쿼리를 관찰
const observer = new QueryObserver(queryClient, options);
// useSyncExternalStore로 Observer의 결과를 구독
const result = useSyncExternalStore(
// subscribe: Observer의 결과가 바뀔 때 React에 알림
(callback) => observer.subscribe(callback),
// getSnapshot: 현재 쿼리 결과를 반환
() => observer.getOptimisticResult(options),
// getServerSnapshot: SSR용
() => observer.getOptimisticResult(options)
);
return result;
}TanStack Query의 캐시는 React 외부에 존재한다. 백그라운드 리프레시가 완료되거나, 다른 컴포넌트에서 invalidateQueries를 호출하면 캐시가 업데이트된다. 이 변경을 React의 concurrent 렌더링과 안전하게 동기화하려면 useSyncExternalStore가 필수다.
// 시나리오: 캐시가 백그라운드에서 업데이트됨
[Component A] → useQuery('todos') → stale data (v1)
↓ 백그라운드 refetch 완료: v1 → v2
[Component B] → useQuery('todos') → fresh data (v2)
// useSyncExternalStore 없이: A는 v1, B는 v2 → Tearing
// useSyncExternalStore 있으면: 둘 다 v2로 일관된 UI아래 다이어그램은 두 라이브러리가 useSyncExternalStore를 중심으로 어떻게 구조화되는지 보여준다:
flowchart LR
subgraph Zustand
ZS["Vanilla Store"] -->|getState| ZB["useStore"]
ZS -->|subscribe| ZB
end
subgraph TanStack["TanStack Query"]
QC["QueryCache"] -->|observe| QO["QueryObserver"]
QO -->|getOptimisticResult| TB["useBaseQuery"]
QO -->|subscribe| TB
end
ZB --> USE["useSyncExternalStore"]
TB --> USE
USE --> React["React Concurrent Renderer"]
style React fill:#61dafb,color:#000
style USE fill:#f4a261,color:#000| 외부 스토어 | subscribe | getSnapshot | |
|---|---|---|---|
| Zustand | Vanilla Store (클라이언트 상태) | store.subscribe | selector(store.getState()) |
| TanStack Query | QueryCache (서버 상태) | observer.subscribe | observer.getOptimisticResult() |
결국 패턴은 동일하다:
subscribe로 변경을 구독한다getSnapshot으로 현재 스냅샷을 동기적으로 읽는다이 훅이 하나의 표준 인터페이스로 자리잡으면서, 외부 상태 라이브러리의 설계 철학 자체가 바뀌었다. 예전에는 각 라이브러리가 자체적으로 React와의 동기화 로직을 구현했지만, 이제는 subscribe + getSnapshot이라는 계약(contract)만 충족하면 React가 나머지를 보장한다. Zustand, TanStack Query뿐 아니라 Redux, Valtio, 심지어 Jotai까지 이 훅을 채택하거나 채택을 논의하고 있다.
핵심 인사이트:
useSyncExternalStore는 단순한 유틸리티 훅이 아니다. React가 "외부 세계와의 경계"를 공식적으로 정의한 아키텍처적 선언이다. 이 경계를 이해하면, 어떤 상태 관리 라이브러리를 만나더라도 그 내부 동작을 예측할 수 있다.
이론을 이해했다면, 실제로 Tearing을 눈으로 확인해보자.
startTransition 안에서 외부 스토어를 사용할 때 Tearing이 발생하는 재현 코드다:
// 외부 스토어 (React 관리 밖)
let externalCount = 0;
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
function increment() {
externalCount++;
listeners.forEach(l => l());
}
// ❌ useEffect 기반 구독 — tearing 발생 가능
function useBrokenStore() {
const [count, setCount] = useState(externalCount);
useEffect(() => {
return subscribe(() => setCount(externalCount));
}, []);
return count;
}
// 수백 개의 Cell이 동시에 이 값을 읽음
function Cell() {
const count = useBrokenStore();
return <div style= background: count % 2 ? 'red' : 'blue' >{count}</div>;
}
function App() {
return (
<>
<button onClick={() => startTransition(() => increment())}>
Increment in Transition
</button>
<div style= display: 'grid', gridTemplateColumns: 'repeat(20, 1fr)' >
{Array.from({length: 400}, (_, i) => <Cell key={i} />)}
</div>
</>
);
}이 코드를 실행하고 버튼을 빠르게 클릭하면, 일부 Cell은 빨간색(홀수), 일부는 파란색(짝수)이 되는 시각적 Tearing을 확인할 수 있다.
// ✅ 해결: useSyncExternalStore로 교체
function useFixedStore() {
return useSyncExternalStore(subscribe, () => externalCount);
}Tearing이 의심될 때 확인해야 할 항목들:
| 실수 | 증상 | 해결 |
|---|---|---|
getSnapshot에서 매번 새 객체 생성 | 무한 리렌더링, 성능 저하 | 스토어 내부에서 immutable snapshot 관리 |
subscribe에서 구독 해제 함수 미반환 | 메모리 누수, 좀비 구독 | return () => listeners.delete(cb) 필수 |
getServerSnapshot 미제공 (SSR) | Hydration mismatch 에러 | 서버에서 안전한 초기값 반환 |
| 셀렉터 없이 전체 state 구독 (Zustand) | 무관한 상태 변경에도 리렌더 | useStore(s => s.count) 형태로 슬라이스 |
<StrictMode>는 개발 모드에서 Effect를 2번 실행한다. 이는 구독 누수를 조기에 발견하는 데 도움이 된다. subscribe의 클린업 함수가 정상 작동하는지 꼭 확인하자.React 생태계는 빠르게 진화하고 있다. useSyncExternalStore의 관점에서 주요 변화들을 살펴보자.
use() 훅 — 대체제가 아니다React 19에서 도입된 use() 훅은 Promise와 Context를 렌더 중에 읽을 수 있게 해준다:
// use()는 Promise/Context 전용
const data = use(fetchTodos()); // ✅ Suspense와 통합
const theme = use(ThemeContext); // ✅ 조건부 Context 읽기
// 하지만 외부 스토어 구독에는 사용할 수 없다
const count = use(zustandStore); // ❌ 이런 건 안 됨use()는 비동기 데이터 페칭과 Context 소비를 위한 훅이다. 외부 스토어 구독이라는 useSyncExternalStore의 역할과는 완전히 다른 영역이므로, 두 훅은 공존하는 관계다.
React Compiler(구 React Forget)는 useMemo, useCallback, React.memo를 자동으로 적용해준다. 이것이 useSyncExternalStore에 미치는 영향은:
// Before: 수동 메모이제이션 필요
const selector = useCallback((s) => s.count, []);
const count = useStore(selector);
// After (React Compiler): 자동 메모이제이션
const count = useStore((s) => s.count); // 컴파일러가 알아서 최적화좋은 소식: Zustand의 셀렉터 패턴이 더 간결해진다. 인라인 셀렉터를 써도 컴파일러가 참조 안정성을 보장한다.
변하지 않는 것: React Compiler는 렌더링 최적화 도구이지, 외부 스토어 동기화 문제를 해결하지 않는다. Tearing 방지는 여전히 useSyncExternalStore의 몫이다.
flowchart TB
subgraph R18["React 18"]
USE18["useSyncExternalStore"]
end
subgraph R19["React 19"]
USE19["useSyncExternalStore"]
USE_HOOK["use() Hook"]
COMPILER["React Compiler"]
end
subgraph Future["Future"]
STORE["Store Protocol?"]
RSC["RSC + External Store"]
end
USE18 --> USE19
R18 --> USE_HOOK
R18 --> COMPILER
USE19 --> STORE
USE_HOOK --> RSC
COMPILER --> STORE
style USE18 fill:#f4a261,color:#000
style USE19 fill:#f4a261,color:#000
style STORE fill:#e9c46a,color:#000React 팀은 외부 스토어와의 통합을 더 개선하는 방향을 모색하고 있다:
getServerSnapshot의 역할이 더 중요해질 수 있다.subscribe + getSnapshot 계약이 사실상의 표준이지만, 공식 프로토콜로 발전할 가능성이 있다.useTransition과 외부 스토어의 상호작용을 더 세밀하게 제어하는 API가 논의되고 있다.useSyncExternalStore를 배우는 것은 단순히 하나의 API를 익히는 것이 아니다. React가 외부 세계와 소통하는 근본 원리를 이해하는 것이다. 이 원리는 React 19에서도, 그 이후에서도 변하지 않는다.기존에 외부 스토어를 구독하던 방식과 비교해보자.
// ❌ Before — useEffect + useState (tearing 발생 가능)
function useExternalStore(store) {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return unsubscribe;
}, [store]);
return state;
}이 패턴의 문제:
useEffect는 커밋 후 비동기적으로 실행되므로, 렌더와 구독 사이에 간극이 존재한다startTransition 안에서 사용하면 tearing이 발생한다// ✅ After — useSyncExternalStore (정합성 보장)
function useExternalStore(store) {
return useSyncExternalStore(
store.subscribe,
store.getState
);
}단 두 줄로 tearing 문제가 근본적으로 해결된다. React가 렌더링 사이클 안에서 직접 스냅샷을 관리하기 때문이다.