Skip to content

@stateloom/core

The reactive kernel. Every other @stateloom/* package builds on these primitives.

Install

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

Size: ~1.5 KB gzipped

Overview

The core provides five exports — signal, computed, effect, batch, and createScope — plus SSR helpers runInScope and serializeScope. These are the building blocks for all paradigm adapters (store, atom, proxy) and middleware.

Quick Start

typescript
import { signal, computed, effect, batch } from '@stateloom/core';

const count = signal(0);
const doubled = computed(() => count.get() * 2);

const dispose = effect(() => {
  console.log(`count=${count.get()}, doubled=${doubled.get()}`);
  return undefined;
});
// Console: "count=0, doubled=0"

batch(() => {
  count.set(5);
  count.update((n) => n + 1);
});
// Console: "count=6, doubled=12" — fires once

dispose(); // cleanup

Guide

Creating Signals

Signals are the fundamental writable primitive. They hold a value and notify dependents when it changes.

typescript
import { signal } from '@stateloom/core';

const count = signal(0);

// Read the current value
count.get(); // 0

// Write a new value
count.set(5);
count.get(); // 5

// Functional update (based on current value)
count.update((n) => n + 1);
count.get(); // 6

By default, equality is checked with Object.is. Setting the same value is a no-op:

typescript
const count = signal(0);
count.set(0); // no notification — value unchanged

For objects, pass a custom equality function:

typescript
const user = signal(
  { name: 'Alice', age: 30 },
  { equals: (a, b) => a.name === b.name && a.age === b.age },
);

user.set({ name: 'Alice', age: 30 }); // no notification — custom equality says equal

Deriving Values with Computed

Computed values derive from signals automatically. The derivation function is auto-tracked — any signal read during execution becomes a dependency.

typescript
import { signal, computed } from '@stateloom/core';

const firstName = signal('Alice');
const lastName = signal('Smith');
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);

fullName.get(); // "Alice Smith"

firstName.set('Bob');
fullName.get(); // "Bob Smith" — recomputed because firstName changed
fullName.get(); // "Bob Smith" — cached, fn does not re-execute

Computed values are lazy — the derivation function only runs when .get() is called and a dependency has changed. They are also memoized — if the computed result hasn't changed (per the equality function), downstream nodes are not notified.

Computeds can depend on other computeds:

typescript
const count = signal(0);
const doubled = computed(() => count.get() * 2);
const quadrupled = computed(() => doubled.get() * 2);

quadrupled.get(); // 0
count.set(3);
quadrupled.get(); // 12

Reacting to Changes with Effects

Effects run side effects that automatically re-execute when their dependencies change.

typescript
import { signal, effect } from '@stateloom/core';

const count = signal(0);

const dispose = effect(() => {
  console.log('Count is:', count.get());
  return undefined;
});
// Console: "Count is: 0" — runs immediately

count.set(1);
// Console: "Count is: 1" — re-runs synchronously at the end of set()

The initial run is synchronous. Subsequent re-executions are also synchronous — each signal.set() wraps its propagation in an internal batch, and effects run at the end of that batch.

WARNING

Without batch(), each signal.set() triggers immediate effect re-execution. If you update multiple related signals separately, effects may see intermediate states. Always use batch() when updating multiple signals that should be seen together:

typescript
const a = signal(0);
const b = signal(0);

effect(() => {
  console.log(a.get(), b.get());
  return undefined;
});
// Console: 0 0

// BAD — effect runs twice, sees intermediate (1, 0)
a.set(1); // Console: 1 0
b.set(2); // Console: 1 2

// GOOD — effect runs once with consistent state
batch(() => {
  a.set(1);
  b.set(2);
});
// Console: 1 2

Cleanup Functions

If the effect function returns a function, it is treated as cleanup. Cleanup runs before each re-execution and when the effect is disposed:

typescript
import { signal, effect } from '@stateloom/core';

const url = signal('/api/data');

const dispose = effect(() => {
  const controller = new AbortController();
  fetch(url.get(), { signal: controller.signal });

  return () => controller.abort(); // cleanup: cancel in-flight request
});

url.set('/api/other'); // cleanup runs synchronously, then effect re-executes

dispose(); // final cleanup runs, effect stops permanently

Disposing Effects

Call the dispose function returned by effect() to stop it permanently:

typescript
const dispose = effect(() => {
  // ...
  return undefined;
});

dispose(); // stops the effect, runs cleanup, releases all subscriptions
dispose(); // idempotent — no-op on subsequent calls

Batching Signal Writes

Use batch() to coalesce multiple signal writes into a single notification cycle. This is essential when updating multiple related signals — without batch(), effects re-run after each set() and may see inconsistent intermediate states:

typescript
import { signal, effect, batch } from '@stateloom/core';

const firstName = signal('Alice');
const lastName = signal('Smith');

effect(() => {
  console.log(`${firstName.get()} ${lastName.get()}`);
  return undefined;
});
// Console: "Alice Smith"

batch(() => {
  firstName.set('Bob');
  lastName.set('Jones');
});
// Console: "Bob Jones" — effect fires once, not twice

Values are updated immediately inside the batch — signal.get() returns the new value. But subscriber notifications and effect re-executions are deferred until the outermost batch completes.

Batches nest safely:

typescript
batch(() => {
  count.set(1);
  batch(() => {
    count.set(2);
    // inner batch end: nothing flushes yet
  });
  // outer batch end: all notifications flush
});

Subscribing to Value Changes

Both signals and computeds support .subscribe() for imperative callback-based listening:

typescript
import { signal, computed } from '@stateloom/core';

const count = signal(0);
const doubled = computed(() => count.get() * 2);

const unsub = doubled.subscribe((value) => {
  console.log('Doubled changed to:', value);
});

count.set(5);
// Console: "Doubled changed to: 10" — runs synchronously at end of set()

unsub(); // stop listening

This is the primary integration point for framework adapters — they subscribe to Subscribable<T> to bridge into framework-specific reactivity.

Advanced: Dynamic Dependencies

Effects and computed values track dependencies dynamically. If a branch is not taken, dependencies in that branch are not tracked:

typescript
import { signal, computed } from '@stateloom/core';

const showDetails = signal(false);
const details = signal('secret');

const display = computed(() => {
  if (showDetails.get()) {
    return details.get(); // only tracked when showDetails is true
  }
  return 'hidden';
});

display.get(); // "hidden" — only depends on showDetails
details.set('new secret'); // does NOT trigger recomputation of display

showDetails.set(true);
display.get(); // "new secret" — now depends on both showDetails and details

Advanced: SSR Scopes

For server-side rendering, use scopes to isolate state between requests:

typescript
import { createScope, runInScope, serializeScope, signal } from '@stateloom/core';

const count = signal(0);

// Each request gets its own scope
async function handleRequest() {
  const scope = createScope();

  runInScope(scope, () => {
    scope.set(count, 42);
    scope.get(count); // 42
  });

  count.get(); // 0 — global state unchanged

  // Serialize for client hydration
  const data = serializeScope(scope);
  return data;
}

Scopes can be forked for nested isolation:

typescript
const parent = createScope();
parent.set(count, 10);

const child = parent.fork();
child.get(count); // 10 — inherits from parent

child.set(count, 20);
child.get(count); // 20 — child override
parent.get(count); // 10 — parent unchanged

API Reference

signal<T>(initialValue: T, options?: SignalOptions<T>): Signal<T>

Create a mutable reactive value container.

Parameters:

ParameterTypeDescriptionDefault
initialValueTThe initial value of the signal.
optionsSignalOptions<T> | undefinedOptional configuration.undefined
options.equals(a: T, b: T) => booleanCustom equality function.Object.is

Returns: Signal<T> — a writable signal with get(), set(), update(), and subscribe() methods.

typescript
import { signal } from '@stateloom/core';

const count = signal(0);
count.get(); // 0
count.set(5); // notifies dependents
count.update((n) => n + 1); // 6

Key behaviors:

  • Equality check uses Object.is by default — identical values do not trigger notifications
  • NaN is considered equal to NaN (Object.is(NaN, NaN) is true)
  • +0 and -0 are considered different (Object.is(+0, -0) is false)
  • Each set() call wraps propagation in an internal batch — dependent effects and subscriber notifications run synchronously at the end of set()
  • Inside an explicit batch(), notifications are deferred until the outermost batch completes
  • When updating multiple related signals, use batch() to prevent effects from seeing intermediate states
  • Signal reads inside computed() or effect() are automatically tracked as dependencies
  • Signals implement Subscribable<T> — they work directly with any framework adapter

See also: computed(), effect(), batch()


computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySignal<T>

Create a lazy, memoized derived value. The function fn is auto-tracked — any signal or computed read during execution becomes a dependency.

Parameters:

ParameterTypeDescriptionDefault
fn() => TThe derivation function. Must be synchronous and pure.
optionsSignalOptions<T> | undefinedOptional configuration.undefined
options.equals(a: T, b: T) => booleanCustom equality function for the derived value.Object.is

Returns: ReadonlySignal<T> — a read-only signal whose value is derived from fn.

typescript
import { signal, computed } from '@stateloom/core';

const count = signal(0);
const doubled = computed(() => count.get() * 2);

doubled.get(); // 0
count.set(5);
doubled.get(); // 10

Key behaviors:

  • Lazy: The derivation function only executes when .get() is called and the value is stale
  • Memoized: If dependencies haven't changed, returns the cached value without re-executing fn
  • Glitch-free: In diamond dependency graphs (D depends on B and C, both depend on A), D always sees a consistent state — no intermediate values are exposed
  • Computed values are read-only — there is no .set() method
  • Dependencies are tracked dynamically — conditional branches only track what they read
  • Errors thrown in fn propagate to the caller of .get()
  • The custom equals function compares the new result against the cached value — if equal, downstream dependents are not notified

See also: signal(), effect()


effect(fn: () => (() => void) | undefined): () => void

Create a side-effect that auto-tracks dependencies and re-runs when they change.

Parameters:

ParameterTypeDescriptionDefault
fn() => (() => void) | undefinedThe effect function. May return a cleanup function.

Returns: () => void — a dispose function that stops the effect permanently.

typescript
import { signal, effect } from '@stateloom/core';

const count = signal(0);

const dispose = effect(() => {
  console.log('Count:', count.get());
  return () => console.log('Cleanup');
});
// Console: "Count: 0"

count.set(1);
// Console: "Cleanup"
// Console: "Count: 1"

dispose();
// Console: "Cleanup"

Key behaviors:

  • The initial run is synchronous (executes immediately when effect() is called)
  • Re-executions are also synchronous — each signal.set() wraps in an internal batch, and effects run at the end of that batch
  • Inside an explicit batch(), effect re-execution is deferred until the outermost batch completes
  • Without batch(), each signal.set() triggers immediate effect re-execution — use batch() when updating multiple related signals to avoid intermediate states
  • The return value of fn is treated as a cleanup function, called before each re-execution and on dispose
  • Dependencies are tracked dynamically — conditional branches only track what they read
  • dispose() is idempotent — calling it multiple times is safe
  • dispose() runs the final cleanup and releases all dependency subscriptions
  • Errors thrown during execution propagate to the caller

See also: signal(), computed(), batch()


batch(fn: () => void): void

Coalesce multiple signal writes into a single notification cycle.

Parameters:

ParameterTypeDescriptionDefault
fn() => voidThe function containing batched signal writes.

Returns: void

typescript
import { signal, effect, batch } from '@stateloom/core';

const a = signal(0);
const b = signal(0);

effect(() => {
  console.log(a.get(), b.get());
  return undefined;
});
// Console: 0 0

batch(() => {
  a.set(1);
  b.set(2);
});
// Console: 1 2 — effect fires once with consistent state

Key behaviors:

  • Without batch(), each signal.set() triggers immediate synchronous effect re-execution — effects may see intermediate states when multiple signals are updated separately
  • batch() groups signal writes so effects only run once with the final consistent state
  • Values are updated immediately inside the batch — signal.get() returns the new value
  • Subscriber notifications (.subscribe() callbacks) are deferred until the outermost batch completes
  • Effect re-execution is deferred until the outermost batch completes
  • Batches can be nested — only the outermost batch triggers flushing
  • If fn throws, the batch still ends (via finally) and pending notifications/effects flush

See also: signal(), effect()


createScope(): Scope

Create an isolated state scope for SSR.

Parameters: None.

Returns: Scope — a new empty scope.

typescript
import { createScope, runInScope, signal } from '@stateloom/core';

const count = signal(0);
const scope = createScope();

runInScope(scope, () => {
  scope.set(count, 42);
  scope.get(count); // 42
});

Key behaviors:

  • Each scope is an isolated state universe — signal values are scoped, not global
  • Scopes are lightweight — they only store values that have been explicitly set
  • scope.fork() creates a child scope inheriting parent values
  • scope.get() falls back to parent scope, then to the signal's global value
  • scope.set() only affects reads within this scope and its children

See also: runInScope(), serializeScope()


runInScope<T>(scope: Scope, fn: () => T): T

Run a callback with the given scope as the active scope.

Parameters:

ParameterTypeDescriptionDefault
scopeScopeThe scope to activate.
fn() => TThe function to execute within the scope.

Returns: T — the return value of fn.

typescript
import { createScope, runInScope } from '@stateloom/core';

const scope = createScope();
const result = runInScope(scope, () => 'done');
// result === 'done'

Key behaviors:

  • Scopes nest correctly — inner runInScope calls take precedence
  • The outer scope is restored when the callback returns (even if it throws)
  • Async functions work correctly — the scope is active for the synchronous portion

See also: createScope(), serializeScope()


serializeScope(scope: Scope): Record<string, unknown>

Serialize the scope's state for client transfer.

Parameters:

ParameterTypeDescriptionDefault
scopeScopeThe scope to serialize.

Returns: Record<string, unknown> — a plain object mapping serialization keys to values.

typescript
import { createScope, serializeScope, signal } from '@stateloom/core';

const count = signal(0);
const scope = createScope();
scope.set(count, 10);

const data = serializeScope(scope);
// { __scope_0: 10 }

Key behaviors:

  • Serialization keys are stable (assigned once per subscribable, based on creation order)
  • Uses WeakMap internally — subscribables are not retained after garbage collection
  • Only values explicitly set in the scope are serialized

See also: createScope(), runInScope()


Subscribable<T> (interface)

Universal contract for reactive values. Every signal, computed, store, and atom implements this interface. Framework adapters bridge this single interface to framework-specific reactivity.

typescript
interface Subscribable<T> {
  get(): T;
  subscribe(callback: (value: T) => void): () => void;
}

This is the integration point for framework adapters — any Subscribable<T> can be consumed by any @stateloom/* framework adapter.


ReadonlySignal<T> (interface)

A read-only reactive value. Returned by computed(). Extends Subscribable<T>.

typescript
interface ReadonlySignal<T> extends Subscribable<T> {
  get(): T;
  subscribe(callback: (value: T) => void): () => void;
}

Signal<T> (interface)

A writable reactive value container. Returned by signal(). Extends ReadonlySignal<T>.

typescript
interface Signal<T> extends ReadonlySignal<T> {
  set(value: T): void;
  update(fn: (current: T) => T): void;
}

SignalOptions<T> (interface)

Options for creating a signal or computed.

typescript
interface SignalOptions<T> {
  readonly equals?: (a: T, b: T) => boolean;
}
PropertyTypeDescriptionDefault
equals(a: T, b: T) => boolean | undefinedCustom equality function.Object.is

Scope (interface)

SSR isolation scope. See createScope() for usage.

typescript
interface Scope {
  fork(): Scope;
  get<T>(subscribable: Subscribable<T>): T;
  set<T>(signal: Signal<T>, value: T): void;
  serialize(): Record<string, unknown>;
}

Patterns

Derived State (Computed Chains)

Build complex derived state by chaining computeds:

typescript
import { signal, computed } from '@stateloom/core';

const items = signal([
  { name: 'Apple', price: 1.5, quantity: 3 },
  { name: 'Banana', price: 0.5, quantity: 6 },
]);

const subtotals = computed(() => items.get().map((item) => item.price * item.quantity));

const total = computed(() => subtotals.get().reduce((sum, s) => sum + s, 0));

total.get(); // 7.5

Async Side Effects

Use effects with cleanup for async operations:

typescript
import { signal, effect } from '@stateloom/core';

const userId = signal(1);

effect(() => {
  const id = userId.get();
  const controller = new AbortController();

  fetch(`/api/users/${String(id)}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => {
      // handle data
    })
    .catch(() => {
      // handle error or abort
    });

  return () => controller.abort();
});

Toggle Pattern

Conditional dependencies for toggling visibility:

typescript
import { signal, computed } from '@stateloom/core';

const isOpen = signal(false);
const data = signal({ items: 100 });

const displayText = computed(() => {
  if (!isOpen.get()) return 'Panel closed';
  return `Showing ${String(data.get().items)} items`;
});

displayText.get(); // "Panel closed" — only depends on isOpen
isOpen.set(true);
displayText.get(); // "Showing 100 items" — now depends on both

Counter with History

Composition of signals for undo support:

typescript
import { signal, computed, batch } from '@stateloom/core';

const count = signal(0);
const history = signal<number[]>([0]);

const canUndo = computed(() => history.get().length > 1);

function increment() {
  batch(() => {
    const next = count.get() + 1;
    count.set(next);
    history.update((h) => [...h, next]);
  });
}

function undo() {
  if (!canUndo.get()) return;
  batch(() => {
    const h = history.get();
    const prev = h[h.length - 2];
    if (prev !== undefined) {
      count.set(prev);
      history.set(h.slice(0, -1));
    }
  });
}

How It Works

Push-Pull Hybrid Algorithm

The core uses a push-pull hybrid reactive algorithm:

  1. Push phase (eager): When signal.set(newValue) passes the equality check, the signal's version increments and dirty marks propagate through all downstream computed and effect nodes. Each set() wraps this in an internal batch.
  2. Pull phase (lazy): When the internal batch ends, queued effects run synchronously. They call .get() on their dependencies. Dirty computed nodes recompute; clean ones return cached values.

This avoids redundant computation (computeds only recompute when read). However, without an explicit batch(), each signal.set() flushes its own internal batch, so effects run immediately after each set. Use batch() to group multiple signal writes and ensure effects see a consistent state.

Dirty State Flags

Each consumer node (computed or effect) carries bitwise flags:

FlagValueMeaning
CLEAN0Up-to-date, no recomputation needed
MAYBE_DIRTY1A dependency might have changed — check source versions
DIRTY2Definitely stale — must recompute on next read
NOTIFIED4Effect is queued for execution (prevents duplicate scheduling)
DISPOSED8Effect is permanently stopped

When a signal changes, it marks direct computed subscribers as DIRTY. Those computeds mark their downstream subscribers as MAYBE_DIRTY. This avoids unnecessary recomputation in chains — a MAYBE_DIRTY computed first checks whether its sources actually changed before recomputing.

Doubly-Linked Dependency Nodes

Dependencies are tracked via shared Link nodes participating in two doubly-linked lists:

Each Link connects one source to one consumer and participates in:

  • The source's subscriber list (prevSub/nextSub) — used during dirty propagation
  • The consumer's dependency list (prevDep/nextDep) — used during re-evaluation

This gives O(1) add/remove without Set allocation overhead. During re-evaluation, existing links are reused (cursor optimization for same-order access) — no garbage creation in the common case.

Effect Scheduling

  • Initial run: Synchronous (runs immediately when effect() is called)
  • Re-execution after signal.set(): Synchronous. Each set() wraps propagation in an internal batch. When the batch ends, effects run immediately. This means each set() call triggers effect re-execution before set() returns.
  • Inside explicit batch(): Effects are deferred to a queue. When the outermost batch ends, subscriber notifications flush first, then effects flush.
  • Without batch(): Each signal.set() triggers its own effect flush. If you set multiple signals separately, effects see intermediate states. Use batch() for consistency.

Batch Flushing Order

When a batch completes:

  1. Subscriber notifications flush first (synchronous .subscribe() callbacks)
  2. Effects flush second (scheduled effect() re-executions)

This ensures that imperative listeners see updates before effects run their side effects.

TypeScript

Type inference works automatically in most cases:

typescript
import { signal, computed, effect } from '@stateloom/core';
import { expectTypeOf } from 'vitest';

// Signal type inferred from initial value
const count = signal(42);
expectTypeOf(count.get()).toEqualTypeOf<number>();

// Computed type inferred from return value
const doubled = computed(() => count.get() * 2);
expectTypeOf(doubled.get()).toEqualTypeOf<number>();

// Explicit generic for union types
const nullable = signal<string | null>(null);
expectTypeOf(nullable.get()).toEqualTypeOf<string | null>();

// Subscribe callback receives the value type
count.subscribe((value) => {
  expectTypeOf(value).toEqualTypeOf<number>();
});

// Update callback receives the current type
count.update((current) => {
  expectTypeOf(current).toEqualTypeOf<number>();
  return current + 1;
});

// Effect returns dispose function
const dispose = effect(() => undefined);
expectTypeOf(dispose).toEqualTypeOf<() => void>();

When to Use

Use @stateloom/core directly when:

ScenarioWhy Core
Framework-agnostic reactive stateNo framework dependency
Vanilla JS, Web Workers, Node.jsZero platform APIs in core
Building a custom paradigm adapterDirect access to signal/computed/effect
Maximum control, minimum abstraction~1.5 KB, no opinions
Bundle size is the primary constraintSmallest possible footprint

For most application development, use a paradigm adapter (@stateloom/store, @stateloom/atom, or @stateloom/proxy) paired with a framework adapter (@stateloom/react, etc.). The paradigm adapters provide higher-level patterns (actions, selectors, middleware) while the framework adapters handle subscription management and re-rendering.