Skip to content

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:

typescript
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.

typescript
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:

  1. Same state reference: Store hasn't changed at all (e.g., React's strict mode double-invoking).
  2. Equal selection: State changed, but the selected slice didn't (e.g., changing name when the component only selects count).

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:

typescript
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:

typescript
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:

typescript
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:

typescript
() => (scope ? scope.get(subscribable) : subscribable.get());

During SSR:

  • If a ScopeProvider wraps the component, scope.get(subscribable) returns the request-scoped value
  • If no ScopeProvider exists, 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

ConcernStrategyCost
Selector memoizationCache (state, selection) pair; skip re-render when selection unchangedO(1) cache lookup per getSnapshot call
Stable subscribe refuseCallback keyed on store/signal identityZero re-subscriptions on re-render
Inline selector identityuseRef avoids recreating getSnapshotOne ref read per getSnapshot
Server snapshotScope context read only during SSRZero overhead on client
useSetAtomNo subscription — write-only pathZero re-renders for write-only components
Adapter size~200 lines across all entrypointsTree-shakeable per paradigm
Concurrent renderinguseSyncExternalStore prevents tearingFramework-managed (no adapter overhead)

Cross-References