React Adapter Design
Low-level design for @stateloom/react — the React framework adapter. Covers the useSyncExternalStore bridging strategy, selector memoization, per-hook implementation details, and SSR scope integration via React Context.
Overview
The React adapter bridges Subscribable<T> from @stateloom/core to React's rendering model via useSyncExternalStore. It provides hooks for all three paradigms (signal, store, atom, proxy) and a ScopeProvider for SSR isolation. The adapter contains no business logic — it is pure bridging code (~200 lines total).
Bridging Strategy
All React hooks use useSyncExternalStore (React 18+) as the single integration point. This API requires two arguments: a subscribe function that registers a callback, and a getSnapshot function that returns the current value. React calls getSnapshot during rendering and subscribe to detect when re-renders are needed.
Why useSyncExternalStore
React 18's concurrent rendering can "tear" — reading different values from an external store during a single render pass. useSyncExternalStore is the only safe way to subscribe to external stores in concurrent mode. It coordinates the synchronous read (getSnapshot) and the subscription lifecycle, guaranteeing consistency across concurrent renders.
Implementation Details
useSignal
Bridges any Subscribable<T> to React. Uses both subscribe() and effect() to handle two cases:
export function useSignal<T>(subscribable: Subscribable<T>): T {
const scope = useScopeContext();
const subscribe = useCallback(
(onStoreChange: () => void) => {
const unsub = subscribable.subscribe(() => {
onStoreChange();
});
// Effect-based tracking handles computed signals (pull model).
const dispose = effect(() => {
subscribable.get();
return undefined;
});
return () => {
unsub();
dispose();
};
},
[subscribable],
);
const getSnapshot = useCallback(() => subscribable.get(), [subscribable]);
return useSyncExternalStore(subscribe, getSnapshot, () =>
scope ? scope.get(subscribable) : subscribable.get(),
);
}Why both subscribe() and effect()? Core's computed.subscribe() is lazy — subscribers are only notified during .get(), which triggers the internal #refresh(). The effect() ensures .get() is called when dependencies change, which triggers refresh and fires the subscribe callback. For plain signals, the effect is redundant but harmless. For computed signals, the effect is essential to propagate dependency changes.
Server snapshot: The third argument to useSyncExternalStore is getServerSnapshot. When a ScopeProvider is present, it reads from the scope (scope.get(subscribable)) instead of the global value, ensuring SSR isolation.
useStore (Selector Memoization)
The store hook supports three call signatures: full state, selected slice, and custom equality. The key complexity is selector memoization to prevent unnecessary re-renders.
export function useStore<State, Selection = State>(
store: StoreApi<State>,
selector?: (state: State) => Selection,
equalityFn?: (a: Selection, b: Selection) => boolean,
): Selection {
const selectorRef = useRef(selector);
const equalityFnRef = useRef(equalityFn);
const cacheRef = useRef<SelectorCache<State, Selection> | undefined>(undefined);
selectorRef.current = selector;
equalityFnRef.current = equalityFn;
const getSnapshot = useCallback((): Selection => {
const state = store.getState();
const sel = selectorRef.current;
if (!sel) return state as unknown as Selection;
const cache = cacheRef.current;
// Fast path: same state reference → same selection
if (cache && Object.is(state, cache.state)) return cache.selection;
const nextSelection = sel(state);
// Equality check: keep cached reference if selection unchanged
if (cache) {
const eq = equalityFnRef.current ?? Object.is;
if (eq(cache.selection, nextSelection)) {
cacheRef.current = { state, selection: cache.selection };
return cache.selection;
}
}
cacheRef.current = { state, selection: nextSelection };
return nextSelection;
}, [store]);
const subscribe = useCallback(
(onStoreChange: () => void) => store.subscribe(onStoreChange),
[store],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}Memoization flow:
The memoization prevents re-renders in two scenarios:
- Same state reference: Store hasn't changed at all (e.g., React's strict mode double-invoking).
- Equal selection: State changed, but the selected slice didn't (e.g., changing
namewhen the component only selectscount).
Why refs for selector and equalityFn? Selectors and equality functions may be inline closures that change identity on every render. Storing them in refs avoids recreating the getSnapshot callback on every render, which would cause useSyncExternalStore to re-subscribe unnecessarily.
useSnapshot (Proxy Hook)
Bridges the proxy paradigm's subscribe() and snapshot() to React:
export function useSnapshot<T extends object>(proxy: T): Snapshot<T> {
const sub = useCallback((onStoreChange: () => void) => subscribe(proxy, onStoreChange), [proxy]);
const getSnapshot = useCallback(() => snapshot(proxy), [proxy]);
return useSyncExternalStore(sub, getSnapshot, getSnapshot);
}The proxy's subscribe() fires on any mutation (including nested). snapshot() creates a frozen, structurally shared copy. useSyncExternalStore handles tearing prevention — the snapshot is always consistent with the proxy's current state.
useAtom / useAtomValue / useSetAtom
Three hooks provide read-write, read-only, and write-only access to atoms:
function useAtom<T>(atom: Atom<T>): [T, (value: T) => void] {
const value = useAtomValue(atom);
const set = useSetAtom(atom);
return [value, set];
}
function useAtomValue<T>(atom: AnyReadableAtom<T>): T {
const subscribe = useCallback((cb: () => void) => atom.subscribe(cb), [atom]);
const getSnapshot = useCallback(() => atom.get(), [atom]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
function useSetAtom<T>(atom: Atom<T>): (value: T) => void {
return useCallback(
(value: T) => {
atom.set(value);
},
[atom],
);
}useSetAtom does not subscribe: The component does not re-render when the atom changes. This is useful for write-only scenarios (e.g., increment buttons, form handlers) where reading the value is unnecessary.
useAtomValue accepts AnyReadableAtom<T>: This includes base atoms, derived atoms, and writable atoms — any atom that supports get() and subscribe().
SSR Integration
The React adapter uses React Context for scope injection:
ScopeProvider
A thin wrapper around React.createElement that provides scope via Context:
const ScopeContext = createContext<Scope | null>(null);
function ScopeProvider({ scope, children }: ScopeProviderProps) {
return createElement(ScopeContext.Provider, { value: scope }, children);
}
function useScopeContext(): Scope | null {
return useContext(ScopeContext);
}Server Snapshot Behavior
The useSignal hook passes a third argument to useSyncExternalStore — the getServerSnapshot:
() => (scope ? scope.get(subscribable) : subscribable.get());During SSR:
- If a
ScopeProviderwraps the component,scope.get(subscribable)returns the request-scoped value - If no
ScopeProviderexists,subscribable.get()returns the global value
Nesting ScopeProvider components is supported — inner providers override outer ones, matching React's standard Context behavior.
Design Decisions
Why useCallback for subscribe and getSnapshot
useSyncExternalStore compares function references between renders. If subscribe changes identity, React tears down the old subscription and creates a new one. useCallback keyed on [store] or [subscribable] ensures stable references unless the source itself changes.
Why useRef for Selector Instead of useCallback Dependency
If the selector were a useCallback dependency, the getSnapshot callback would be recreated on every render (since inline selectors change identity each render). This would cause useSyncExternalStore to re-subscribe on every render. Using useRef decouples the selector's identity from the callback's identity.
Why Separate Entrypoints for Store, Atom, and Proxy
The React adapter uses subpath exports (@stateloom/react/store, @stateloom/react/atom, @stateloom/react/proxy) so that applications only pay for the paradigm bridges they use. An atom-only app never bundles the store or proxy bridge code.
Why ScopeContext Default Is null (Not undefined)
Using null instead of undefined for the default context value makes it explicit that "no scope" is a valid state. It also enables if (scope) checks that are clear about intent.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Selector memoization | Cache (state, selection) pair; skip re-render when selection unchanged | O(1) cache lookup per getSnapshot call |
| Stable subscribe ref | useCallback keyed on store/signal identity | Zero re-subscriptions on re-render |
| Inline selector identity | useRef avoids recreating getSnapshot | One ref read per getSnapshot |
| Server snapshot | Scope context read only during SSR | Zero overhead on client |
| useSetAtom | No subscription — write-only path | Zero re-renders for write-only components |
| Adapter size | ~200 lines across all entrypoints | Tree-shakeable per paradigm |
| Concurrent rendering | useSyncExternalStore prevents tearing | Framework-managed (no adapter overhead) |
Cross-References
- Adapters Overview — cross-cutting adapter patterns and adapter contract
- Architecture Overview — where adapters fit in the layer structure
- Core Design —
Subscribable<T>contract andeffect()internals - Store Design —
StoreApi<T>thatuseStoreconsumes - Atom Design — atom APIs that
useAtomhooks consume - Proxy Design — proxy APIs that
useSnapshotconsumes - API Reference:
@stateloom/react— consumer-facing documentation - React + Vite Guide — framework adoption guide