@stateloom/solid
February 13, 2026 · View on GitHub
Solid.js adapter for StateLoom. Bridges Subscribable<T> to Solid's fine-grained reactivity via createSignal.
Install
::: code-group
pnpm add @stateloom/core @stateloom/solid
npm install @stateloom/core @stateloom/solid
yarn add @stateloom/core @stateloom/solid
:::
Size: ~0.2 KB gzipped
Overview
graph LR
Sub["Subscribable<T>"] --> US["useSignal(sub)"]
Sub --> UST["useStore(store, selector?)"]
US --> Acc["Accessor<T>"]
UST --> Acc
SC["createScope()"] --> SP["ScopeProvider"]
SP --> USC["useScope()"]
style Sub fill:#4a9eff,color:#fff
style US fill:#059669,color:#fff
style UST fill:#059669,color:#fff
style Acc fill:#7c3aed,color:#fff
style SP fill:#dc2626,color:#fff
style USC fill:#dc2626,color:#fff
The adapter provides two reactive hooks (useSignal, useStore) and SSR scope isolation (ScopeProvider, useScope). All hooks return Solid accessors that integrate natively with Solid's tracking system.
Quick Start
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/solid';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
function Counter() {
const value = useSignal(count);
const double = useSignal(doubled);
return (
<div>
{value()} x 2 = {double()}
</div>
);
}
Guide
Subscribing to Signals
Use useSignal to bridge any Subscribable<T> (signal, computed, atom) to a Solid accessor. The accessor updates automatically when the source changes, and cleanup is handled via onCleanup.
import { signal } from '@stateloom/core';
import { useSignal } from '@stateloom/solid';
const name = signal('Alice');
function Greeting() {
const value = useSignal(name);
return <h1>Hello, {value()}</h1>;
}
Subscribing to Stores
Use useStore to bridge a store to a Solid accessor. Without a selector, it returns the full state. With a selector, it returns a derived slice that only triggers updates when the selected value changes.
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/solid';
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// Full state
function FullState() {
const state = useStore(store);
return <span>{state().count}</span>;
}
// Selected slice — only re-renders when count changes
function CountOnly() {
const count = useStore(store, (s) => s.count);
return <span>{count()}</span>;
}
Custom Equality
Pass a custom equality function as the third argument to useStore to control when updates propagate:
import { useStore } from '@stateloom/solid';
function ItemList() {
const items = useStore(
store,
(s) => s.items,
(a, b) => a.length === b.length,
);
return <ul>{/* render items */}</ul>;
}
SSR Scope Isolation
Use ScopeProvider to isolate state per request during server-side rendering. Each request creates its own scope, preventing state leakage between concurrent requests.
import { createScope, signal } from '@stateloom/core';
import { ScopeProvider, useScope } from '@stateloom/solid';
const count = signal(0);
function App() {
const scope = createScope();
scope.set(count, 42);
return (
<ScopeProvider scope={scope}>
<Counter />
</ScopeProvider>
);
}
function Counter() {
const scope = useScope();
// scope is available for SSR-safe reads
return <div />;
}
API Reference
useSignal<T>(subscribable: Subscribable<T>): Accessor<T>
Subscribe to a reactive value in a Solid component. Returns a Solid accessor that stays in sync with the source.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
subscribable | Subscribable<T> | Any signal, computed, or subscribable from StateLoom. | -- |
Returns: Accessor<T> -- a Solid accessor that reads the current value.
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/solid';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
function Display() {
const value = useSignal(count);
const double = useSignal(doubled);
return (
<div>
{value()} x 2 = {double()}
</div>
);
}
Key behaviors:
- Uses
createSignalwithequals: falseinternally to ensure all updates propagate - Tracks via both StateLoom
effect()(for graph-integrated sources) andsubscribe()(for plainSubscribableobjects) - Uses
setValue(() => next)to safely handle function-typed values - Cleanup is automatic via Solid's
onCleanup - Must be called within a Solid reactive context (component,
createRoot,createEffect)
See also: useStore()
useStore<T>(store: Subscribable<T>): Accessor<T>
Subscribe to a store's full state in a Solid component.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
store | Subscribable<T> | Any Subscribable<T>, typically a StoreApi<T>. | -- |
Returns: Accessor<T> -- a Solid accessor for the full state.
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/solid';
const store = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const state = useStore(store);
return <button onClick={state().increment}>{state().count}</button>;
}
Key behaviors:
- Without a selector, behaves identically to
useSignal(store) - Updates on every state change since no selector filters updates
See also: useStore() with selector, useSignal()
useStore<T, U>(store: Subscribable<T>, selector: (state: T) => U, equals?: (a: U, b: U) => boolean): Accessor<U>
Subscribe to a derived slice of a store's state in a Solid component.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
store | Subscribable<T> | Any Subscribable<T>, typically a StoreApi<T>. | -- |
selector | (state: T) => U | Function that extracts a value from the full state. | -- |
equals | (a: U, b: U) => boolean | Custom equality function. | Object.is |
Returns: Accessor<U> -- a Solid accessor for the selected value.
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/solid';
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function CountDisplay() {
const count = useStore(store, (s) => s.count);
return <span>{count()}</span>;
}
Key behaviors:
- The accessor only updates when the selected value changes (per the equality function)
- The selector should be a pure function with no side effects
- Uses both
effect()andsubscribe()internally for full compatibility - Cleanup is automatic via
onCleanup
See also: useSignal()
ScopeProvider(props: ScopeProviderProps): JSX.Element
Provide a StateLoom scope to descendant components for SSR isolation.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
props.scope | Scope | The scope to provide to descendant components. | -- |
props.children | JSX.Element | Child elements that can access the scope. | -- |
Returns: JSX.Element
import { createScope } from '@stateloom/core';
import { ScopeProvider } from '@stateloom/solid';
function App() {
const scope = createScope();
return (
<ScopeProvider scope={scope}>
<MyComponent />
</ScopeProvider>
);
}
Key behaviors:
- Nesting
ScopeProvidercomponents is supported -- inner providers override outer ones - Implemented without JSX to avoid build-time JSX transform requirements
See also: useScope()
useScope(): Scope | undefined
Read the current StateLoom scope from context.
Parameters: None.
Returns: Scope | undefined -- the nearest ancestor's scope, or undefined if none.
import { useScope } from '@stateloom/solid';
function MyComponent() {
const scope = useScope();
if (scope) {
console.log('Rendering within a scope');
}
return <div />;
}
See also: ScopeProvider
ScopeContext
Solid context object for direct context consumption. Prefer useScope() in most cases.
import { useContext } from 'solid-js';
import { ScopeContext } from '@stateloom/solid';
const scope = useContext(ScopeContext);
ScopeProviderProps (interface)
Props for the ScopeProvider component.
interface ScopeProviderProps {
readonly scope: Scope;
readonly children: JSX.Element;
}
Patterns
Counter with Actions
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/solid';
const counterStore = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
}));
function Counter() {
const state = useStore(counterStore);
return (
<div>
<button onClick={state().decrement}>-</button>
<span>{state().count}</span>
<button onClick={state().increment}>+</button>
</div>
);
}
Derived Computed Values
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/solid';
const items = signal([
{ name: 'Apple', price: 1.5 },
{ name: 'Banana', price: 0.5 },
]);
const total = computed(() => items.get().reduce((sum, item) => sum + item.price, 0));
function Total() {
const value = useSignal(total);
return <span>Total: ${value().toFixed(2)}</span>;
}
Atom Integration
Atoms implement Subscribable<T>, so they work directly with useSignal:
import { atom, derived } from '@stateloom/atom';
import { useSignal } from '@stateloom/solid';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
function Counter() {
const count = useSignal(countAtom);
const doubled = useSignal(doubledAtom);
return (
<div>
<span>
{count()} x 2 = {doubled()}
</span>
<button onClick={() => countAtom.set(countAtom.get() + 1)}>+</button>
</div>
);
}
Proxy Integration
Use @stateloom/proxy's subscribe and snapshot with Solid's createSignal:
import { observable, snapshot, subscribe } from '@stateloom/proxy';
import { createSignal, onCleanup } from 'solid-js';
const state = observable({ count: 0, user: { name: 'Alice' } });
function ProxyDisplay() {
const [snap, setSnap] = createSignal(snapshot(state));
const unsub = subscribe(state, () => setSnap(() => snapshot(state)));
onCleanup(unsub);
return (
<div>
{snap().user.name}: {snap().count}
</div>
);
}
// Mutate directly
state.count++;
Batch Updates
import { signal, batch } from '@stateloom/core';
import { useSignal } from '@stateloom/solid';
const firstName = signal('Alice');
const lastName = signal('Smith');
function NameDisplay() {
const first = useSignal(firstName);
const last = useSignal(lastName);
return (
<span>
{first()} {last()}
</span>
);
}
// Batch to avoid intermediate renders
batch(() => {
firstName.set('Bob');
lastName.set('Jones');
});
SSR with SolidStart
import { createScope, signal } from '@stateloom/core';
import { ScopeProvider } from '@stateloom/solid';
const count = signal(0);
export default function Page() {
const scope = createScope();
scope.set(count, 42);
return (
<ScopeProvider scope={scope}>
<App />
</ScopeProvider>
);
}
How It Works
Bridge Architecture
The adapter uses a dual-subscription strategy:
sequenceDiagram
participant C as Component
participant US as useSignal
participant CS as createSignal
participant E as StateLoom effect()
participant Sub as .subscribe()
C->>US: useSignal(subscribable)
US->>CS: createSignal(initial, {equals: false})
US->>E: effect(() => setValue(sub.get()))
US->>Sub: sub.subscribe(setValue)
Note over E,Sub: Dual subscription ensures both<br/>graph-integrated and plain<br/>subscribables trigger updates
E-->>CS: setValue on graph change
Sub-->>CS: setValue on subscribe notification
CS-->>C: accessor() reads current value
- StateLoom
effect()tracks the subscribable in the reactive graph. When upstream signals change, the effect re-runs and updates the Solid signal. subscribe()handles plainSubscribableobjects that don't participate in the reactive graph.onCleanupdisposes both subscriptions when the component unmounts.
Selector Optimization in useStore
useStore with a selector compares selected values using the equality function before calling setValue. This prevents Solid from re-rendering when unrelated parts of the store change:
- Store notifies of state change
- Selector extracts new value
- Equality check against previous selected value
- If different, update the Solid signal
- If equal, skip (no re-render)
TypeScript
import { signal, computed } from '@stateloom/core';
import { createStore } from '@stateloom/store';
import { useSignal, useStore } from '@stateloom/solid';
import { expectTypeOf } from 'vitest';
// useSignal infers from the subscribable
const count = signal(42);
const accessor = useSignal(count);
expectTypeOf(accessor).toEqualTypeOf<() => number>();
// useStore without selector returns full state type
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
const full = useStore(store);
expectTypeOf(full()).toHaveProperty('count');
expectTypeOf(full()).toHaveProperty('name');
// useStore with selector infers the selected type
const selected = useStore(store, (s) => s.count);
expectTypeOf(selected).toEqualTypeOf<() => number>();
Migration
From Solid's Built-in Signals
Solid's native createSignal and StateLoom's signal serve different purposes. StateLoom signals are framework-agnostic and participate in a shared reactive graph, while Solid's signals are Solid-specific:
// Solid native
import { createSignal } from 'solid-js'; // [!code --]
const [count, setCount] = createSignal(0); // [!code --]
// [!code --]
function Counter() {
// [!code --]
return <span>{count()}</span>; // [!code --]
} // [!code --]
// StateLoom (shared across frameworks)
import { signal } from '@stateloom/core'; // [!code ++]
import { useSignal } from '@stateloom/solid'; // [!code ++]
const count = signal(0); // [!code ++]
// [!code ++]
function Counter() {
// [!code ++]
const value = useSignal(count); // [!code ++]
return <span>{value()}</span>; // [!code ++]
} // [!code ++]
Key differences:
- StateLoom signals are shared across frameworks and components outside the Solid tree
- StateLoom provides middleware, persistence, and devtools for signals
- Use StateLoom signals for cross-framework shared state; keep Solid's signals for component-local state
From Solid's createStore
// Solid native
import { createStore } from 'solid-js/store'; // [!code --]
const [store, setStore] = createStore({ count: 0 }); // [!code --]
// StateLoom
import { createStore } from '@stateloom/store'; // [!code ++]
import { useStore } from '@stateloom/solid'; // [!code ++]
const store = createStore((set) => ({
// [!code ++]
count: 0, // [!code ++]
increment: () => set((s) => ({ count: s.count + 1 })), // [!code ++]
})); // [!code ++]
// [!code ++]
function Counter() {
// [!code ++]
const count = useStore(store, (s) => s.count); // [!code ++]
return <span>{count()}</span>; // [!code ++]
} // [!code ++]
When to Use
| Scenario | Why @stateloom/solid |
|---|---|
| Solid.js application with StateLoom state | Native Solid accessor integration |
| Fine-grained reactivity alignment | Solid's signals + StateLoom signals = natural fit |
| Store-based state with selectors | useStore provides selector optimization |
| Atom-based state | useSignal(atom) works directly |
| SSR with SolidStart | ScopeProvider isolates state per request |
Solid's fine-grained reactivity aligns naturally with StateLoom's signal system. The adapter is minimal (~0.2 KB) because both systems share the same reactive paradigm. For React, Vue, Svelte, or Angular projects, use the corresponding adapter instead.
See the full Solid + Vite example app for a complete working application.