Skip to content

Solid Adapter Design

Low-level design for @stateloom/solid — the Solid.js framework adapter. Covers the dual subscription strategy (effect() + subscribe()), createSignal bridging, selector memoization, and SSR scope integration via Solid's context API.

Overview

The Solid adapter bridges Subscribable<T> from @stateloom/core to Solid's fine-grained reactivity via createSignal. It uses a dual subscription model — both StateLoom's effect() and subscribe() — to handle graph-integrated and plain subscribables. The adapter provides two hooks (useSignal, useStore) and a ScopeProvider for SSR isolation. The adapter is pure bridging code (~130 lines total).

Bridging Strategy: Dual Subscription

The Solid adapter subscribes to StateLoom sources via two channels simultaneously:

  1. effect() (StateLoom core) — tracks graph-integrated sources (signals, computed) that use push-pull propagation
  2. subscribe() (StateLoom) — catches plain Subscribable objects that don't participate in the reactive graph

Why Both effect() and subscribe()

Core effect() handles graph-integrated sources (signals, computed) that participate in push-pull propagation. When a computed signal's dependencies change, the effect re-runs and reads the new value. However, plain Subscribable objects (e.g., custom adapters, third-party sources) don't participate in the reactive graph — they only fire subscribe() callbacks. Using both ensures the adapter works with any Subscribable<T> implementation.

For graph-integrated sources, the subscribe() callback is redundant — the effect() already handles updates. The duplication is harmless because Solid's setValue with { equals: false } always triggers an update regardless of duplicate calls.

Implementation Details

useSignal

typescript
export function useSignal<T>(subscribable: Subscribable<T>): Accessor<T> {
  const [value, setValue] = createSignal<T>(subscribable.get(), {
    equals: false,
  });

  // Core effect for graph-integrated subscribables
  const dispose = effect(() => {
    const next = subscribable.get();
    setValue(() => next);
    return undefined;
  });

  // Subscribe for non-graph subscribables
  const unsubscribe = subscribable.subscribe((next) => {
    setValue(() => next);
  });

  onCleanup(() => {
    dispose();
    unsubscribe();
  });

  return value;
}

{ equals: false }: Disables Solid's built-in equality check on the internal signal. StateLoom handles equality semantics upstream (signals use Object.is by default, stores always produce new references). Disabling Solid's check avoids double-checking equality and ensures every StateLoom notification triggers a Solid update.

setValue(() => next) pattern: Solid's setValue interprets function arguments as updater functions. If T is a function type (e.g., Subscribable<() => void>), passing the function directly would invoke it as an updater. Wrapping in () => next ensures the value is set directly, regardless of T.

onCleanup: Solid's cleanup mechanism ties to the enclosing reactive scope (component, createRoot, createEffect). When the scope disposes, both the StateLoom effect and subscription are cleaned up.

useStore (with Selector)

Follows the same dual-subscription pattern with an equality-checked selector:

typescript
export function useStore<T, U = T>(
  store: Subscribable<T>,
  selector?: (state: T) => U,
  equals: (a: U, b: U) => boolean = Object.is,
): Accessor<U> {
  const select = selector ?? ((state: T) => state as unknown as U);
  let currentValue = select(store.get());
  const [value, setValue] = createSignal<U>(currentValue, { equals: false });

  const update = (state: T) => {
    const nextValue = select(state);
    if (!equals(currentValue, nextValue)) {
      currentValue = nextValue;
      setValue(() => nextValue);
    }
  };

  const dispose = effect(() => {
    update(store.get());
    return undefined;
  });

  const unsubscribe = store.subscribe((next) => {
    update(next);
  });

  onCleanup(() => {
    dispose();
    unsubscribe();
  });

  return value;
}

Shared update function: Both the effect callback and the subscribe callback route through the same update function. This ensures consistent equality checking and value caching regardless of which channel triggers the update.

currentValue closure variable: Similar to the Vue adapter, the equality check uses a closure variable rather than reading the Solid signal. Reading the Solid signal inside the effect would create a Solid dependency (via Solid's own tracking), which is undesirable — the effect should only track StateLoom dependencies.

SSR Integration

The Solid adapter uses Solid's createContext and useContext for scope injection:

typescript
const ScopeContext = createContext<Scope | undefined>(undefined);

function ScopeProvider(props: ScopeProviderProps): JSX.Element {
  return ScopeContext.Provider({
    value: props.scope,
    get children() {
      return props.children;
    },
  });
}

function useScope(): Scope | undefined {
  return useContext(ScopeContext);
}

Why No JSX in ScopeProvider

The ScopeProvider is implemented by calling ScopeContext.Provider() directly rather than using JSX (<ScopeContext.Provider>). This avoids requiring a JSX transform at build time for the library package. Consumers of the library use JSX normally in their components, but the library itself ships plain JavaScript.

Children Getter Pattern

typescript
get children() {
  return props.children;
}

The get children() getter preserves Solid's reactivity contract. In Solid, props.children is a getter (not a plain value). Using get children() ensures the children are resolved lazily, matching Solid's expectation for component children. A direct children: props.children assignment would eagerly evaluate the children, breaking lazy component resolution.

Design Decisions

Why Solid Doesn't Use useSyncExternalStore

Unlike React, Solid has true fine-grained reactivity — individual DOM nodes subscribe to specific signals. There is no "virtual DOM diffing" or "render phase" that could tear. Solid's createSignal is the natural integration point. The value written to the Solid signal triggers fine-grained DOM updates for exactly the nodes that read that signal.

Why equals: false on createSignal

StateLoom's signals and stores handle equality semantics internally. A StateLoom signal with Object.is equality won't fire subscribers for equal values. By disabling Solid's equality check (equals: false), the adapter avoids double-checking and ensures that every StateLoom notification is faithfully forwarded to Solid's reactivity system. This is safe because StateLoom guarantees meaningful change semantics upstream.

Why Accessor<T> Return Type

Returning Accessor<T> (Solid's read-only signal type) matches Solid's ergonomic convention. Components access the value by calling the accessor: value(). This integrates naturally with Solid's JSX — <div>{value()}</div> — and participates in Solid's fine-grained tracking.

Why No useSnapshot for Proxy Paradigm

Solid's fine-grained reactivity model doesn't need a snapshot layer. Solid components track individual property accesses — mutating state.count only re-renders the exact DOM node that reads count. The proxy paradigm's snapshot concept (frozen point-in-time copy) is designed for React's reconciliation model. In Solid, useSignal on the proxy's subscribable provides the same result without the snapshot overhead.

Performance Considerations

ConcernStrategyCost
Dual subscriptionBoth effect() and subscribe() for full coverageTwo cleanup calls per hook instance
equals: falseDefer equality to StateLoom; avoid double-checkZero Solid-side comparison cost
setValue(() => next)Function wrapper for type safetyOne extra closure per update
Selector equalityCheck before setValue; prevent Solid updatesO(1) equality check per state change
Fine-grained updatesSolid signals trigger per-node DOM updatesMinimal DOM work per change
CleanuponCleanup ties to Solid's reactive scope lifecycleAutomatic — no manual disposal
Adapter size~130 lines totalTree-shakeable per hook

Cross-References