@stateloom/core
The reactive kernel. Every other @stateloom/* package builds on these primitives.
Install
pnpm add @stateloom/corenpm install @stateloom/coreyarn add @stateloom/coreSize: ~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
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(); // cleanupGuide
Creating Signals
Signals are the fundamental writable primitive. They hold a value and notify dependents when it changes.
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(); // 6By default, equality is checked with Object.is. Setting the same value is a no-op:
const count = signal(0);
count.set(0); // no notification — value unchangedFor objects, pass a custom equality function:
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 equalDeriving Values with Computed
Computed values derive from signals automatically. The derivation function is auto-tracked — any signal read during execution becomes a dependency.
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-executeComputed 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:
const count = signal(0);
const doubled = computed(() => count.get() * 2);
const quadrupled = computed(() => doubled.get() * 2);
quadrupled.get(); // 0
count.set(3);
quadrupled.get(); // 12Reacting to Changes with Effects
Effects run side effects that automatically re-execute when their dependencies change.
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:
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 2Cleanup 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:
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 permanentlyDisposing Effects
Call the dispose function returned by effect() to stop it permanently:
const dispose = effect(() => {
// ...
return undefined;
});
dispose(); // stops the effect, runs cleanup, releases all subscriptions
dispose(); // idempotent — no-op on subsequent callsBatching 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:
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 twiceValues 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:
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:
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 listeningThis 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:
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 detailsAdvanced: SSR Scopes
For server-side rendering, use scopes to isolate state between requests:
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:
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 unchangedAPI Reference
signal<T>(initialValue: T, options?: SignalOptions<T>): Signal<T>
Create a mutable reactive value container.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
initialValue | T | The initial value of the signal. | — |
options | SignalOptions<T> | undefined | Optional configuration. | undefined |
options.equals | (a: T, b: T) => boolean | Custom equality function. | Object.is |
Returns: Signal<T> — a writable signal with get(), set(), update(), and subscribe() methods.
import { signal } from '@stateloom/core';
const count = signal(0);
count.get(); // 0
count.set(5); // notifies dependents
count.update((n) => n + 1); // 6Key behaviors:
- Equality check uses
Object.isby default — identical values do not trigger notifications NaNis considered equal toNaN(Object.is(NaN, NaN)istrue)+0and-0are considered different (Object.is(+0, -0)isfalse)- Each
set()call wraps propagation in an internal batch — dependent effects and subscriber notifications run synchronously at the end ofset() - 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()oreffect()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:
| Parameter | Type | Description | Default |
|---|---|---|---|
fn | () => T | The derivation function. Must be synchronous and pure. | — |
options | SignalOptions<T> | undefined | Optional configuration. | undefined |
options.equals | (a: T, b: T) => boolean | Custom equality function for the derived value. | Object.is |
Returns: ReadonlySignal<T> — a read-only signal whose value is derived from fn.
import { signal, computed } from '@stateloom/core';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
doubled.get(); // 0
count.set(5);
doubled.get(); // 10Key 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
fnpropagate to the caller of.get() - The custom
equalsfunction compares the new result against the cached value — if equal, downstream dependents are not notified
effect(fn: () => (() => void) | undefined): () => void
Create a side-effect that auto-tracks dependencies and re-runs when they change.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
fn | () => (() => void) | undefined | The effect function. May return a cleanup function. | — |
Returns: () => void — a dispose function that stops the effect permanently.
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(), eachsignal.set()triggers immediate effect re-execution — usebatch()when updating multiple related signals to avoid intermediate states - The return value of
fnis 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 safedispose()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:
| Parameter | Type | Description | Default |
|---|---|---|---|
fn | () => void | The function containing batched signal writes. | — |
Returns: void
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 stateKey behaviors:
- Without
batch(), eachsignal.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
fnthrows, the batch still ends (viafinally) and pending notifications/effects flush
createScope(): Scope
Create an isolated state scope for SSR.
Parameters: None.
Returns: Scope — a new empty scope.
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 valuesscope.get()falls back to parent scope, then to the signal's global valuescope.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:
| Parameter | Type | Description | Default |
|---|---|---|---|
scope | Scope | The scope to activate. | — |
fn | () => T | The function to execute within the scope. | — |
Returns: T — the return value of fn.
import { createScope, runInScope } from '@stateloom/core';
const scope = createScope();
const result = runInScope(scope, () => 'done');
// result === 'done'Key behaviors:
- Scopes nest correctly — inner
runInScopecalls 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:
| Parameter | Type | Description | Default |
|---|---|---|---|
scope | Scope | The scope to serialize. | — |
Returns: Record<string, unknown> — a plain object mapping serialization keys to values.
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
WeakMapinternally — 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.
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>.
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>.
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.
interface SignalOptions<T> {
readonly equals?: (a: T, b: T) => boolean;
}| Property | Type | Description | Default |
|---|---|---|---|
equals | (a: T, b: T) => boolean | undefined | Custom equality function. | Object.is |
Scope (interface)
SSR isolation scope. See createScope() for usage.
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:
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.5Async Side Effects
Use effects with cleanup for async operations:
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:
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 bothCounter with History
Composition of signals for undo support:
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:
- 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. Eachset()wraps this in an internal batch. - 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:
| Flag | Value | Meaning |
|---|---|---|
CLEAN | 0 | Up-to-date, no recomputation needed |
MAYBE_DIRTY | 1 | A dependency might have changed — check source versions |
DIRTY | 2 | Definitely stale — must recompute on next read |
NOTIFIED | 4 | Effect is queued for execution (prevents duplicate scheduling) |
DISPOSED | 8 | Effect 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. Eachset()wraps propagation in an internal batch. When the batch ends, effects run immediately. This means eachset()call triggers effect re-execution beforeset()returns. - Inside explicit
batch(): Effects are deferred to a queue. When the outermost batch ends, subscriber notifications flush first, then effects flush. - Without
batch(): Eachsignal.set()triggers its own effect flush. If you set multiple signals separately, effects see intermediate states. Usebatch()for consistency.
Batch Flushing Order
When a batch completes:
- Subscriber notifications flush first (synchronous
.subscribe()callbacks) - 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:
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:
| Scenario | Why Core |
|---|---|
| Framework-agnostic reactive state | No framework dependency |
| Vanilla JS, Web Workers, Node.js | Zero platform APIs in core |
| Building a custom paradigm adapter | Direct access to signal/computed/effect |
| Maximum control, minimum abstraction | ~1.5 KB, no opinions |
| Bundle size is the primary constraint | Smallest 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.