
외부 상태는 프레임워크 내부에서 만들어진 상태가 아니다. React의 useState, Vue의 ref, Svelte의 $state, Solid의 createSignal처럼 프레임워크가 직접 소유하고 추적하는 값이 아니라, 프레임워크 바깥에서 먼저 존재하고 바깥에서 변하는 값이다. WebSocket 메시지, RxJS Observable, 브라우저 API, 직접 만든 event emitter, Redux나 Zustand 같은 독립 store가 모두 여기에 속한다. UI는 이 값을 화면에 반영해야 하지만, 프레임워크는 그 값이 언제 어떻게 바뀌는지 처음부터 알지 못한다.
그래서 외부 상태 연결은 늘 일종의 번역 작업이 된다. 외부 시스템은 자기 방식대로 상태를 보관하고 변경을 알린다. 프레임워크는 자기 방식대로 렌더링을 예약하고 dependency를 추적한다. 둘 사이에 필요한 것은 거창한 추상화가 아니라, 아주 작은 계약이다. 현재 값을 읽을 수 있어야 하고, 값이 바뀌었을 때 알림을 받을 수 있어야 하며, 더 이상 필요하지 않을 때 그 알림을 해제할 수 있어야 한다.
현재 값 읽기 + 변경 구독 + 구독 해제 = 외부 상태 연결
이 글에서는 이미 준비된 store를 각 프레임워크의 반응성 모델에 어떻게 연결할 수 있는지 살펴본다. 예시는 @ilokesto/store를 사용하지만, 초점은 store 자체의 소개가 아니라 프레임워크별 어댑터의 모양이다. 같은 store라도 React에서는 snapshot 함수가 되고, Vue에서는 shallowRef에 얹히며, Svelte에서는 store contract로 번역되고, Angular와 Solid에서는 각각 Signal 또는 signal graph로 흘러 들어간다.
import { Store } from "@ilokesto/store";
type CounterState = {
count: number;
};
export const counterStore = new Store<CounterState>({ count: 0 });
export function increment() {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}이 글의 예시는 불변 스냅샷을 전제로 한다. 같은 참조를 계속 돌려주는 store라면 프레임워크 입장에서는 변경을 감지하기 어렵다. 그래서 객체나 배열 상태를 다룰 때는 내부를 직접 바꾸기보다 새 참조를 만들어 교체하는 쪽이 외부 상태 어댑터와 잘 맞는다.
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));반대로 아래처럼 이전 객체를 직접 바꾸고 같은 참조를 반환하는 방식은 외부 상태 연결에서 피하는 편이 좋다.
counterStore.setState((prev) => {
prev.count += 1;
return prev;
});이 불변 스냅샷 규칙은 여러 프레임워크와 잘 맞는다. React는 snapshot의 참조 안정성을 중요하게 보고, Vue의 shallowRef는 참조 교체를 기준으로 반응성을 트리거하며, Svelte의 readable store는 새 값을 set할 때 구독자에게 전달하고, Solid의 signal도 새 값이 들어올 때 dependency graph를 갱신한다. 결국 프레임워크별 문법은 달라도 외부 store가 제공해야 하는 핵심 모양은 비슷하다.
React: useSyncExternalStore는 스냅샷 계약을 요구한다
React에서 외부 store를 연결할 때 가장 정석적인 API는 useSyncExternalStore다. 이 훅은 React 18에서 도입되었고, concurrent rendering 환경에서도 외부 상태를 일관되게 읽기 위해 설계되었다. React는 외부 store의 내부 mutation을 직접 추적하지 않는다. 대신 렌더링 시점에 snapshot을 읽고, 변경 알림이 오면 다시 snapshot을 읽는다.
React 쪽 어댑터는 다음처럼 작게 만들 수 있다.
import { useSyncExternalStore } from "react";
import { Store } from "@ilokesto/store";
type CounterState = {
count: number;
};
const counterStore = new Store<CounterState>({ count: 0 });
function useCounterStore() {
return useSyncExternalStore(
(listener) => counterStore.subscribe(listener),
() => counterStore.getState(),
() => counterStore.getInitialState(),
);
}
export function Counter() {
const counter = useCounterStore();
return (
<button
type="button"
onClick={() => {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}}
>
count: {counter.count}
</button>
);
}useSyncExternalStore가 요구하는 것은 세 가지다. 첫 번째는 subscribe(listener)다. 외부 상태가 바뀌면 listener를 호출해야 하고, 구독을 정리할 수 있도록 unsubscribe 함수를 반환해야 한다. 두 번째는 getSnapshot()이다. React는 렌더링 중 이 함수를 호출해 현재 값을 읽는다. 세 번째는 getServerSnapshot()이다. 서버 렌더링 환경에서 사용할 초기 snapshot을 제공한다.
위 코드에서 counterStore.subscribe나 counterStore.getState를 메서드 그대로 넘기지 않고 화살표 함수로 감싼 이유는 클래스 메서드의 this 바인딩 때문이다. 메서드를 분리해 넘기는 대신 () => counterStore.getState()처럼 호출 위치를 고정해두면 어댑터 코드가 더 안전하다.
React에서 특히 중요한 것은 getSnapshot()의 참조 안정성이다. 같은 상태라면 같은 참조를 반환해야 한다. 다음 코드는 겉으로는 문제가 없어 보이지만, 호출할 때마다 새 객체를 만들기 때문에 React 입장에서는 매번 다른 snapshot으로 보인다.
function getSnapshot() {
return {
count: counterStore.getState().count,
};
}이런 함수는 값이 실제로 바뀌지 않았는데도 React에게 “새 상태가 왔다”고 말하는 셈이다. 반대로 store의 현재 snapshot을 그대로 반환하면 상태가 교체될 때만 참조가 바뀐다.
function getSnapshot() {
return counterStore.getState();
}이 구조는 React의 pull 기반 읽기 모델과 잘 맞는다. React는 외부 store를 signal처럼 세밀하게 추적하지 않는다. 렌더링 시점에 값을 끌어와 읽고, 이전 snapshot과 현재 snapshot을 비교해 업데이트 여부를 판단한다. 따라서 외부 store는 실제 변경이 있을 때만 새 참조를 만들어야 하고, React 어댑터는 그 snapshot을 그대로 전달해야 한다.
SSR에서는 getInitialState()가 좋은 기준점이 될 수 있다. 서버에서는 브라우저에서 일어난 최신 상호작용이나 실시간 연결 상태를 알 수 없다. 그래서 서버 렌더링에 사용할 안정적인 초기값을 따로 제공해야 한다.
useSyncExternalStore(
(listener) => counterStore.subscribe(listener),
() => counterStore.getState(),
() => counterStore.getInitialState(),
);물론 모든 경우에 초기 상태가 서버 snapshot으로 충분한 것은 아니다. 브라우저 width, media query, localStorage처럼 서버에서 알 수 없는 값은 별도의 서버 기본값이 필요할 수 있다. 다만 생성 시점의 상태를 따로 제공하는 store라면 getInitialState()는 서버와 클라이언트의 첫 렌더링 기준을 맞추는 실용적인 선택지가 된다.
Vue 3: 외부 스냅샷을 shallowRef에 얇게 얹는다
Vue 3에서는 외부 상태를 Vue의 reactivity graph 안으로 들여오기 위해 ref 계열 primitive를 사용할 수 있다. 하지만 외부 store의 snapshot은 이미 store가 소유하고 관리하는 값이다. Vue가 객체 내부까지 깊게 proxy로 감싸는 것보다, snapshot 참조가 교체되는지만 추적하는 편이 더 안전하다. 그래서 외부 상태 어댑터에서는 보통 ref보다 shallowRef가 자연스럽다.
import { shallowRef, onScopeDispose } from "vue";
import type { Store } from "@ilokesto/store";
export function useStoreSnapshot<T>(store: Store<T>) {
const state = shallowRef(store.getState());
const unsubscribe = store.subscribe(() => {
state.value = store.getState();
});
onScopeDispose(unsubscribe);
return state;
}이 composable의 흐름은 단순하다. 처음에는 getState()로 현재 snapshot을 읽어 shallowRef에 담는다. store가 변경을 알리면 다시 getState()를 읽어 state.value를 교체한다. composable이 속한 scope가 사라지면 onScopeDispose에서 unsubscribe를 호출한다.
<script setup lang="ts">
import { Store } from "@ilokesto/store";
import { useStoreSnapshot } from "./use-store-snapshot";
const counterStore = new Store({ count: 0 });
const counter = useStoreSnapshot(counterStore);
function increment() {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}
</script>
<template>
<button type="button" @click="increment">
count: {{ counter.count }}
</button>
</template>여기서 shallowRef를 쓰는 이유는 ownership boundary를 지키기 위해서다. 외부 상태의 실제 변경 책임은 store에 있고, Vue는 그 결과 snapshot을 화면에 반영할 뿐이다. 만약 일반 ref를 사용해 snapshot 내부 객체까지 깊게 reactive proxy로 바꾸기 시작하면, 외부 store의 불변 스냅샷 모델과 Vue의 deep reactivity가 섞일 수 있다.
shallowRef는 내부 속성 mutation을 추적하지 않는다. 그래서 아래와 같은 변경은 Vue에게 자동으로 감지되지 않는다.
const counter = useStoreSnapshot(counterStore);
counter.value.count += 1;이런 코드는 애초에 권장되지 않는다. snapshot은 외부 store가 소유하는 값이므로, UI 쪽에서 직접 변경하기보다 store의 setState()를 통해 새 snapshot으로 교체해야 한다.
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));만약 정말로 외부 시스템이 내부 mutation을 수행하고 같은 객체를 유지하는 구조라면 Vue에서는 triggerRef 같은 수동 트리거가 필요할 수 있다.
import { shallowRef, triggerRef } from "vue";
const state = shallowRef(counterStore.getState());
state.value.count += 1;
triggerRef(state);하지만 이 글의 예시처럼 snapshot 교체를 기본으로 삼는 store라면, 이런 방식보다 새 snapshot을 만들어 setState()로 교체하는 쪽이 더 자연스럽다. 불변 업데이트를 일관되게 유지하면 프레임워크 어댑터도 단순해진다.
Vue에서는 더 세밀한 제어가 필요할 때 customRef도 사용할 수 있다. 예를 들어 외부 store 변경을 debounce해서 Vue에 전달하고 싶다면, dependency tracking과 trigger 시점을 직접 조절할 수 있다.
import { customRef, onScopeDispose } from "vue";
import type { Store } from "@ilokesto/store";
export function useDebouncedStoreSnapshot<T>(store: Store<T>, delay = 200) {
let value = store.getState();
let timeout: ReturnType<typeof setTimeout> | undefined;
const state = customRef<Readonly<T>>((track, trigger) => {
const unsubscribe = store.subscribe(() => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
value = store.getState();
trigger();
}, delay);
});
onScopeDispose(() => {
if (timeout) {
clearTimeout(timeout);
}
unsubscribe();
});
return {
get() {
track();
return value;
},
set() {
throw new Error("Store snapshot is read-only. Use store.setState().");
},
};
});
return state;
}일반적인 연결에는 shallowRef만으로 충분하다. 그러나 debounce, throttle, polling, 수동 invalidate, 외부 SDK 이벤트처럼 trigger 시점을 조절해야 하는 경우에는 customRef가 어댑터 역할을 더 정교하게 수행한다. 중요한 것은 여전히 같다. 외부 store의 ownership은 유지하고, Vue에는 변경된 snapshot만 전달한다.
Svelte 5: store contract와 runes를 구분한다
Svelte에서 외부 상태를 연결하는 가장 직접적인 방법은 store contract를 사용하는 것이다. Svelte store는 본질적으로 subscribe 메서드를 가진 객체다. 컴포넌트는 $store 문법을 통해 값을 읽고, Svelte 컴파일러는 subscription과 cleanup 코드를 자동으로 생성한다.
예시 store의 subscribe()는 listener에게 값을 직접 넘기지 않고 “변경되었다”는 신호만 보낸다. 그래서 Svelte readable store로 감쌀 때는 listener 안에서 다시 getState()를 읽어 set()에 전달하면 된다.
import { readable } from "svelte/store";
import type { Store } from "@ilokesto/store";
export function toReadableStore<T>(store: Store<T>) {
return readable(store.getState(), (set) => {
const unsubscribe = store.subscribe(() => {
set(store.getState());
});
return unsubscribe;
});
}컴포넌트에서는 평범한 Svelte store처럼 사용할 수 있다.
<script lang="ts">
import { Store } from "@ilokesto/store";
import { toReadableStore } from "./to-readable-store";
const counterStore = new Store({ count: 0 });
const counter = toReadableStore(counterStore);
function increment() {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}
</script>
<button type="button" on:click={increment}>
count: {$counter.count}
</button>Svelte store contract가 강력한 이유는 단순함에 있다. subscribe 기반 push stream은 Svelte store로 번역하기 쉽다. RxJS Observable, WebSocket 메시지, event emitter, polling 데이터 소스처럼 “값이 바깥에서 밀려오는” 구조와 잘 맞는다. 어댑터는 변경 이벤트를 받는 순간 최신 snapshot을 읽어 Svelte store로 전달한다.
Svelte 5에서는 runes도 함께 고려해야 한다. $state는 공유 가능한 reactive object를 만들 때 강력하다.
// shared-counter.svelte.ts
export const counter = $state({
count: 0,
});그리고 컴포넌트나 모듈에서 assignment로 값을 바꾸면 Svelte의 반응성 graph가 이를 추적한다.
counter.count += 1;하지만 $state는 기본적으로 Svelte가 소유하는 내부 reactive state에 가깝다. 반면 외부 store는 이미 자신만의 상태 저장, 업데이트, subscription lifecycle을 가지고 있다. 이런 외부 push source를 억지로 $state로 흡수하려고 하면 ownership boundary가 흐려진다. 외부 store를 Svelte 세계로 들여올 때는 여전히 store contract가 더 자연스럽다.
물론 runes를 사용해 직접 연결할 수도 있다. 이 경우에는 $effect의 cleanup 반환이 중요하다.
<script lang="ts">
import { Store } from "@ilokesto/store";
const counterStore = new Store({ count: 0 });
let counter = $state(counterStore.getState());
$effect(() => {
const unsubscribe = counterStore.subscribe(() => {
counter = counterStore.getState();
});
return unsubscribe;
});
function increment() {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}
</script>
<button type="button" onclick={increment}>
count: {counter.count}
</button>이 패턴은 외부 subscription을 Svelte effect lifecycle에 연결한다. effect가 dispose되면 unsubscribe가 실행된다. effect가 다시 실행되는 상황이라면 이전 cleanup이 먼저 수행된다. 즉 외부 상태 연결에서 필요한 “구독 생성 → 변경 반영 → cleanup” 흐름을 $effect 안에 넣는 것이다.
다만 subscribe contract가 이미 명확한 외부 source라면 readable store로 번역하는 쪽이 더 Svelte답다. $state와 runes는 내부 reactive state를 만들 때 강하고, store contract는 외부 push stream을 Svelte 컴파일러가 이해하는 구독 모델로 가져올 때 강하다. 둘은 경쟁 관계라기보다 서로 다른 경계를 담당하는 도구에 가깝다.
Angular: Observable과 Signal 사이를 연결한다
Angular는 오랫동안 RxJS Observable을 중심으로 비동기 흐름을 다뤄왔다. HTTP 요청, router event, form state, 실시간 이벤트는 Observable로 표현되는 경우가 많다. 최근에는 Angular Signal이 핵심 primitive로 추가되면서, UI에서 읽는 현재 상태는 Signal로, 시간에 따라 흐르는 이벤트는 Observable로 다루는 구분이 점점 자연스러워졌다.
Angular에서는 외부 store를 먼저 Observable로 감쌀 수 있다. 예시 store는 listener에게 값을 직접 전달하지 않으므로, subscription callback에서 getState()를 다시 읽어 emit하면 된다.
import { Observable } from "rxjs";
import type { Store } from "@ilokesto/store";
export function toStoreObservable<T>(store: Store<T>) {
return new Observable<Readonly<T>>((subscriber) => {
subscriber.next(store.getState());
const unsubscribe = store.subscribe(() => {
subscriber.next(store.getState());
});
return unsubscribe;
});
}이 Observable은 Angular의 toSignal과 자연스럽게 연결된다.
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Store } from "@ilokesto/store";
import { toStoreObservable } from "./to-store-observable";
const counterStore = new Store({ count: 0 });
@Component({
selector: "app-counter",
template: `
<button type="button" (click)="increment()">
count: {{ counter().count }}
</button>
`,
})
export class CounterComponent {
readonly counter = toSignal(toStoreObservable(counterStore), {
initialValue: counterStore.getInitialState(),
});
increment() {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}
}여기서 initialValue는 중요하다. Observable은 첫 emission이 늦을 수 있지만, Signal은 현재 값을 동기적으로 읽을 수 있어야 한다. store가 getInitialState()를 제공한다면 Angular Signal의 초기값으로 사용하기 좋다. 물론 현재 store가 이미 생성 직후와 다른 상태일 수 있다면 getState()를 초기값으로 둘 수도 있다. 그러나 서버 렌더링이나 초기 렌더링 기준을 명확히 잡고 싶다면 getInitialState()가 의미 있는 기준점이 된다.
반대로 Angular Signal을 RxJS pipeline으로 넘겨야 하는 경우도 있다. 예를 들어 Signal로 관리되는 검색어를 debounce, distinct, switchMap 같은 RxJS operator와 연결하고 싶다면 toObservable을 사용할 수 있다. 이것은 외부 store 어댑터라기보다 Angular 내부 reactive state와 RxJS ecosystem 사이의 다리다.
import { toObservable } from "@angular/core/rxjs-interop";
readonly query$ = toObservable(this.query);Angular에서 외부 상태 연결을 다룰 때 가장 조심해야 할 부분은 lifecycle이다. toSignal은 Angular injection context 안에서 만들어지면 해당 context의 destroy lifecycle에 subscription을 연결한다. 하지만 직접 subscription을 만들었다면 cleanup도 직접 연결해야 한다.
import { DestroyRef, inject } from "@angular/core";
const destroyRef = inject(DestroyRef);
const unsubscribe = counterStore.subscribe(() => {
// read counterStore.getState() and update something
});
destroyRef.onDestroy(() => {
unsubscribe();
});과거에는 OnDestroy interface와 ngOnDestroy()를 통해 cleanup하는 방식이 흔했다.
import { OnDestroy } from "@angular/core";
export class CounterComponent implements OnDestroy {
private readonly unsubscribe = counterStore.subscribe(() => {
// update
});
ngOnDestroy() {
this.unsubscribe();
}
}최근 Angular의 standalone API와 Signal 생태계에서는 DestroyRef가 더 composable한 선택지가 되는 경우가 많다. 방식은 달라도 핵심은 같다. 외부 subscription은 Angular component, directive, service의 lifecycle에 묶여야 한다. 그렇지 않으면 component가 사라진 뒤에도 외부 store listener가 계속 남아 메모리 누수나 중복 업데이트를 만들 수 있다.
Angular 관점에서 외부 상태 연결은 “stream 기반 반응성”과 “state 기반 반응성” 사이를 오가는 작업에 가깝다. Observable은 시간에 따라 흐르는 이벤트를 잘 다루고, Signal은 UI가 지금 읽어야 하는 현재 값을 잘 표현한다. 외부 store는 현재 snapshot과 변경 알림을 제공하고, Angular 어댑터는 그것을 Observable 또는 Signal로 번역한다.
SolidJS: 외부 변경을 signal graph에 밀어 넣는다
SolidJS는 signal 중심의 fine-grained reactivity를 사용한다. React처럼 상태가 바뀔 때 컴포넌트 함수를 통째로 다시 실행하는 모델이 아니라, signal을 읽고 있는 computation과 DOM 조각만 갱신하는 구조에 가깝다. 그래서 외부 store 연결도 비교적 직관적이다. 변경 알림이 오면 signal setter를 호출하면 된다.
import { createSignal, onCleanup } from "solid-js";
import type { Store } from "@ilokesto/store";
export function useStoreSignal<T>(store: Store<T>) {
const [state, setState] = createSignal(store.getState());
const unsubscribe = store.subscribe(() => {
setState(() => store.getState());
});
onCleanup(unsubscribe);
return state;
}setState(() => store.getState())처럼 작성한 이유는 Solid의 setter가 함수 인자를 updater로 해석하기 때문이다. snapshot 자체가 객체라면 단순히 값을 넣어도 동작하지만, 제네릭 유틸리티에서는 함수형 setter 형태로 “이 함수의 반환값을 다음 값으로 사용하라”는 의도를 분명히 하는 편이 안전하다.
import { Store } from "@ilokesto/store";
import { useStoreSignal } from "./use-store-signal";
const counterStore = new Store({ count: 0 });
export function Counter() {
const counter = useStoreSignal(counterStore);
return (
<button
type="button"
onClick={() => {
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
}}
>
count: {counter().count}
</button>
);
}Solid에서 중요한 개념은 owner lifecycle이다. component body, createEffect, createMemo 같은 reactive scope는 owner context 안에서 실행된다. onCleanup은 그 owner가 dispose될 때 함께 실행된다. 외부 store는 component보다 오래 살아 있을 수 있지만, component가 사라진 뒤에도 그 component를 위한 subscription이 남아 있어서는 안 된다.
subscription을 reactive dependency에 따라 다시 만들고 싶다면 createEffect 안에서 연결할 수도 있다.
import { createEffect, createSignal, onCleanup } from "solid-js";
import type { Store } from "@ilokesto/store";
export function useFilteredStoreSignal<T>(store: Store<T>, enabled: () => boolean) {
const [state, setState] = createSignal(store.getState());
createEffect(() => {
if (!enabled()) {
return;
}
const unsubscribe = store.subscribe(() => {
setState(() => store.getState());
});
onCleanup(unsubscribe);
});
return state;
}이때 createEffect가 다시 실행될 수 있다는 점이 중요하다. effect 내부에서 읽은 signal dependency가 바뀌면 이전 cleanup이 먼저 실행되고, 그 다음 새 effect가 실행된다. 그래서 외부 subscription을 effect 안에서 만든다면 cleanup은 선택이 아니라 필수다. cleanup이 없으면 dependency가 바뀔 때마다 subscription이 누적된다.
Solid에서 객체나 중첩 상태를 많이 다룰 때는 createStore도 고려할 수 있다.
import { createStore } from "solid-js/store";
const [state, setState] = createStore({
user: null as null | { name: string },
items: [] as string[],
});
setState("items", (items) => [...items, "book"]);다만 snapshot 전체를 외부에서 받아와 교체하는 구조라면 createSignal이 더 단순한 경우가 많다. createStore는 Solid 내부에서 nested reactivity를 세밀하게 다루고 싶을 때 강하다. 외부 store가 이미 snapshot ownership과 update 전략을 가지고 있다면, Solid 어댑터는 그 snapshot을 signal에 전달하는 얇은 bridge로 남는 편이 좋다.
Solid 관점에서 외부 상태 연결은 “push event를 latest value signal로 바꾸는 일”이다. store가 변경을 알리면 최신 snapshot을 읽어 signal에 넣고, Solid는 그 signal을 읽는 computation만 다시 실행한다. 구조가 단순할수록 Solid의 fine-grained model과도 잘 맞는다.
프레임워크별 차이는 경계 처리 방식의 차이다
지금까지 본 프레임워크들은 서로 다른 반응성 모델을 갖고 있다. React는 렌더링 중 snapshot을 읽고 이전 snapshot과 비교한다. Vue는 shallowRef의 값 교체를 dependency trigger로 삼는다. Svelte는 subscribe contract를 컴파일러가 이해하는 업데이트 경로로 바꾼다. Angular는 Observable과 Signal 사이에서 stream과 현재 상태를 변환한다. Solid는 signal setter를 통해 필요한 computation만 다시 실행한다.
하지만 외부 store 입장에서 보면 어댑터가 해야 할 일은 거의 같다.
const initialSnapshot = store.getInitialState();
const snapshot = store.getState();
const unsubscribe = store.subscribe(() => {
const nextSnapshot = store.getState();
// 프레임워크의 반응성 primitive로 nextSnapshot을 전달한다.
});
// 프레임워크 lifecycle이 끝날 때 unsubscribe를 호출한다.차이는 이 nextSnapshot을 어디에 넣느냐에 있다. React에서는 useSyncExternalStore의 snapshot 함수로 제공한다. Vue에서는 shallowRef.value를 교체한다. Svelte에서는 readable store의 set()에 전달한다. Angular에서는 Observable emission이나 Signal 값으로 변환한다. Solid에서는 signal setter를 호출한다. 문법은 다르지만, 어댑터의 책임은 “외부 변경을 프레임워크가 이해하는 업데이트 신호로 번역하는 것”이다.
이 구조를 지키면 상태의 소유권도 명확해진다. 외부 store는 초기 snapshot과 현재 snapshot, 상태 교체, 변경 알림을 담당한다. 프레임워크 어댑터는 store 내부 규칙을 침범하지 않고, 그 결과 snapshot을 렌더링 시스템에 연결한다. store는 프레임워크를 몰라도 되고, 프레임워크는 store 내부 구현을 몰라도 된다.
외부 상태 연결을 어렵게 만드는 것은 API 이름이 아니라 경계의 혼란이다. 같은 상태인데 getSnapshot()이 매번 새 객체를 만들어 React를 계속 깨우거나, Vue가 외부 snapshot 내부를 직접 mutation하거나, Svelte 컴포넌트가 사라진 뒤에도 subscription이 살아 있거나, Angular에서 subscription cleanup이 누락되거나, Solid effect가 재실행될 때마다 listener가 쌓이면 문제가 생긴다. 반대로 불변 snapshot, 명시적인 구독, 확실한 cleanup을 지키면 대부분의 프레임워크에서 같은 store core를 사용할 수 있다.
그래서 이 글에서 중요한 것은 특정 store의 기능 목록이 아니라, 프레임워크들이 외부 상태를 받아들이는 공통적인 경로다. getState()로 현재 값을 읽고, 필요하다면 getInitialState()로 초기 기준점을 잡고, subscribe()로 변경을 받고, 생명주기가 끝나면 구독을 해제한다. 이 최소 계약은 거의 모든 프론트엔드 프레임워크가 외부 상태를 받아들이는 방식과 맞닿아 있다.
결국 프레임워크 밖의 상태를 UI 안으로 들이는 일은 거대한 상태 관리 철학을 세우는 일이 아니다. 상태를 누가 소유하는지 분명히 하고, 현재 snapshot을 안정적으로 읽고, 변경을 알리고, 생명주기가 끝나면 구독을 정리하는 일이다. 이 작은 다리만 정확하게 놓으면, 같은 store는 React에서도, Vue에서도, Svelte에서도, Angular에서도, Solid에서도 각자의 방식으로 자연스럽게 렌더링 시스템 안으로 들어갈 수 있다.
더 읽어보기
2026.04.23
너만의 월남쌈을 싸
이 포스트의 제목은 유튜브 영상 <너만의 월남쌈을 싸 - 아이네 INE>에서 따왔다. 4월 초부터 @ilokesto 네임스페이스에 속한 거의 모든 라이브러리를 리뉴얼 하고 있다. 이미 deprecated 처리는 끝났고, 새로운 기준 위에서 라이브러리의 구조를 재정의하는 단계를 거치고 있…
2026.05.10
Scope & Disposal — 인스턴스의 생애주기와 정리
이전 포스트를 통해 우리는 인스턴스가 어떻게 만들어지는지 살펴봤다. 이번 파트는 그 반대 방향, 인스턴스가 어떻게 살고 어떻게 사라지는가를 다룬다. fluo DI의 세 가지 스코프(singleton, request, transient)의 생애주기와 dispose()의 역순 정리 메커니즘…
2026.05.10
Instance Creation — resolve에서 new까지
이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막…
2026.05.10
작은 Store 클래스가 상태 관리의 출발점이 되는 이유
상태 관리 라이브러리를 볼 때 먼저 눈에 들어오는 것은 보통 기능 목록이다. selector가 있는지, devtools와 연결되는지, persistence를 지원하는지, React 훅이 준비되어 있는지 같은 것들 말이다. 그런데 @ilokesto/store의 Store 클래스를 보면 질…
2026.05.09
TanStack Router 실무 가이드, 파일 기반 라우팅과 타입 안전하게 React 구조 잡기
React 애플리케이션이 커질수록 라우팅은 단순한 화면 전환 문제가 아니라, URL 설계, 데이터 로딩, 권한 처리, 상태 동기화까지 함께 다뤄야 하는 구조의 문제가 된다. TanStack Router는 이 지점을 정면으로 다룬다. 파일 기반 라우팅을 중심에 두고, URL 파라미터와 검…
댓글
댓글을 불러오는 중...