React 에 싱글톤 패턴 접목하기
싱글톤이 안티패턴이라는 말은 싱글톤이 아니라 React에 붙이던 방식을 겨눈 것이었다.
언제: 프레임워크를 모르는 상태 덩어리(토스트 매니저 같은)를 React에 stale 없이 연결할 때. 코어(표준 JS 클래스)와 어댑터(useSyncExternalStore 한 겹)를 갈라두면 Vue·Svelte는 어댑터만 갈아끼운다.
절차
-
코어를
EventTarget확장으로 만들고, 상태 변경과 이벤트 발행을 setter 하나에 묶는다.private set toasts(value: Toast[]) { this._toasts = [...value] // 새 배열 → 참조 동등성으로 변화 감지 this.dispatchEvent('changed') // 변경 시 발행 누락 불가능 } -
subscribe/getSnapshot어댑터 한 겹을 얹어useSyncExternalStore로 받는다.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) -
쓰기는 코어를 직접 부른다 — React 밖 어디서든.
toastManager.add({ message: '저장됨' })
확인: React 트리 밖(모듈 최상단·다른 라이브러리 콜백)에서 add()를 불러 화면이 갱신되는가. provider도 prop drilling도 없어야 한다.
함정: getSnapshot은 참조가 안정적이어야 한다. 매번 새 객체를 리턴하면 무한 루프 — getter는 내부 배열을 그대로 반환하고, 새 배열은 setter에서만 만든다.
왜 useSyncExternalStore인가 — 연결 방식의 진화:
- polling / 수동 refresh — stale, 데이터 변경과 무관한 리렌더
useEffect+addEventListener— 낫지만 로컬useState를 손으로 동기화 (React state스럽지 않음)useSyncExternalStore— 구독·동기화를 React가 직접 관리, stale closure 제거
곁가지 — 프레임워크 의존을 코어에서 걷어내는 다른 길: 빌드 타임 signal swap(562).