---
type: snippet
tags: ['react', 'usesyncexternalstore', 'singleton', 'architecture']
status: release
ctime: 2026-06-21
mtime: 2026-06-21
---

<img
  src="/svg/singleton_usesyncexternalstore_dataflow.svg"
  alt="EventTarget 코어 싱글톤이 useSyncExternalStore 어댑터를 거쳐 React 컴포넌트로 흐르는 데이터 흐름"
/>

**싱글톤이 안티패턴이라는 통념은 싱글톤 자체가 아니라 React에 붙이는 방식이 후졌던 데서 나왔다.** `EventTarget` 확장 + `useSyncExternalStore`면 프레임워크 무관 코어를 stale 없이 연결한다. 코어(표준 JS 클래스, React를 모름) / 어댑터(`useSyncExternalStore` 한 겹)로 분리 — Vue·Svelte는 어댑터만 갈아끼운다.

연결 방식의 진화:

1. polling / 수동 refresh — stale, 데이터 변경과 무관한 리렌더
2. `useEffect` + `addEventListener` — 낫지만 로컬 `useState`를 손으로 동기화 (React state스럽지 않음)
3. `useSyncExternalStore` — 구독·동기화를 React가 직접 관리, stale closure 제거

```tsx
const subscribe = (cb: () => void) => {
  const ac = new AbortController()
  toastManager.addEventListener('changed', cb, { signal: ac.signal })
  return () => ac.abort()
}
const getSnapshot = () => toastManager.toasts

const toasts = useSyncExternalStore(subscribe, getSnapshot)
```

코어의 핵심은 setter 하나 — 상태 변경과 이벤트 발행을 원자적으로 묶는다:

```ts
private set toasts(value: Toast[]) {
  this._toasts = [...value]      // 새 배열 → 참조 동등성으로 변화 감지
  this.dispatchEvent('changed')  // 변경 시 발행 누락 불가능
}
```

함정: `getSnapshot`은 참조가 안정적이어야 한다. 매번 새 객체를 리턴하면 무한 루프 — getter는 내부 배열을 그대로 반환하고, 새 배열은 setter에서만 만든다.

같은 계열 — 프레임워크 *밖*에 source of truth를 두고 UI는 구독만 하는 사고방식: build-time swap([562](/memo/562)), IndexedDB-first(561).

---

- 출처: [React: Singletons aren't as evil as you think — Andrew Bone](https://dev.to/link2twenty/react-singletons-arent-as-evil-as-you-think-44m8)
- [Type-Safe CustomEvents (TypedEventTarget)](https://dev.to/link2twenty/type-safe-customevents-better-messaging-with-native-apis-2dol)
