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:
effect()(StateLoom core) — tracks graph-integrated sources (signals, computed) that use push-pull propagationsubscribe()(StateLoom) — catches plainSubscribableobjects 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
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:
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:
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
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
| Concern | Strategy | Cost |
|---|---|---|
| Dual subscription | Both effect() and subscribe() for full coverage | Two cleanup calls per hook instance |
| equals: false | Defer equality to StateLoom; avoid double-check | Zero Solid-side comparison cost |
| setValue(() => next) | Function wrapper for type safety | One extra closure per update |
| Selector equality | Check before setValue; prevent Solid updates | O(1) equality check per state change |
| Fine-grained updates | Solid signals trigger per-node DOM updates | Minimal DOM work per change |
| Cleanup | onCleanup ties to Solid's reactive scope lifecycle | Automatic — no manual disposal |
| Adapter size | ~130 lines total | Tree-shakeable per hook |
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 consumed via
useSignal - Proxy Design — proxy APIs consumed via
useSignal - API Reference:
@stateloom/solid— consumer-facing documentation - Solid Guide — framework adoption guide