Skip to content

@stateloom/solid

Solid.js adapter for StateLoom. Bridges Subscribable<T> to Solid's fine-grained reactivity via createSignal.

Install

bash
pnpm add @stateloom/core @stateloom/solid
bash
npm install @stateloom/core @stateloom/solid
bash
yarn add @stateloom/core @stateloom/solid

Size: ~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

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

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

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

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

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

ParameterTypeDescriptionDefault
subscribableSubscribable<T>Any signal, computed, or subscribable from StateLoom.--

Returns: Accessor<T> -- a Solid accessor that reads the current value.

tsx
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 createSignal with equals: false internally to ensure all updates propagate
  • Tracks via both StateLoom effect() (for graph-integrated sources) and subscribe() (for plain Subscribable objects)
  • 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:

ParameterTypeDescriptionDefault
storeSubscribable<T>Any Subscribable<T>, typically a StoreApi<T>.--

Returns: Accessor<T> -- a Solid accessor for the full state.

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

ParameterTypeDescriptionDefault
storeSubscribable<T>Any Subscribable<T>, typically a StoreApi<T>.--
selector(state: T) => UFunction that extracts a value from the full state.--
equals(a: U, b: U) => booleanCustom equality function.Object.is

Returns: Accessor<U> -- a Solid accessor for the selected value.

tsx
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() and subscribe() 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:

ParameterTypeDescriptionDefault
props.scopeScopeThe scope to provide to descendant components.--
props.childrenJSX.ElementChild elements that can access the scope.--

Returns: JSX.Element

tsx
import { createScope } from '@stateloom/core';
import { ScopeProvider } from '@stateloom/solid';

function App() {
  const scope = createScope();
  return (
    <ScopeProvider scope={scope}>
      <MyComponent />
    </ScopeProvider>
  );
}

Key behaviors:

  • Nesting ScopeProvider components 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.

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

tsx
import { useContext } from 'solid-js';
import { ScopeContext } from '@stateloom/solid';

const scope = useContext(ScopeContext);

ScopeProviderProps (interface)

Props for the ScopeProvider component.

typescript
interface ScopeProviderProps {
  readonly scope: Scope;
  readonly children: JSX.Element;
}

Patterns

Counter with Actions

tsx
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

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

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

tsx
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

tsx
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

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

  1. StateLoom effect() tracks the subscribable in the reactive graph. When upstream signals change, the effect re-runs and updates the Solid signal.
  2. subscribe() handles plain Subscribable objects that don't participate in the reactive graph.
  3. onCleanup disposes 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:

  1. Store notifies of state change
  2. Selector extracts new value
  3. Equality check against previous selected value
  4. If different, update the Solid signal
  5. If equal, skip (no re-render)

TypeScript

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:

tsx
// 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

tsx
// 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

ScenarioWhy @stateloom/solid
Solid.js application with StateLoom stateNative Solid accessor integration
Fine-grained reactivity alignmentSolid's signals + StateLoom signals = natural fit
Store-based state with selectorsuseStore provides selector optimization
Atom-based stateuseSignal(atom) works directly
SSR with SolidStartScopeProvider 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.