@stateloom/solid
Solid.js adapter for StateLoom. Bridges Subscribable<T> to Solid's fine-grained reactivity via createSignal.
Install
pnpm add @stateloom/core @stateloom/solidnpm install @stateloom/core @stateloom/solidyarn add @stateloom/core @stateloom/solidSize: ~0.2 KB gzipped
Overview
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:
- 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';
const [count, setCount] = createSignal(0);
function Counter() {
return <span>{count()}</span>;
}
// StateLoom (shared across frameworks)
import { signal } from '@stateloom/core';
import { useSignal } from '@stateloom/solid';
const count = signal(0);
function Counter() {
const value = useSignal(count);
return <span>{value()}</span>;
} 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';
const [store, setStore] = createStore({ count: 0 });
// StateLoom
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 count = useStore(store, (s) => s.count);
return <span>{count()}</span>;
} 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.