@wrnexus/reactive
Small type-safe reactive signal primitives.
Install the package
After WorkRoot approves private registry access, install the release-aligned package:
bun add @wrnexus/reactive@0.8.7Request preview access. Never put registry tokens in source control.
Tiny, type-safe reactive primitives (signals) with zero dependencies.
Part of the WRNexusJS framework — an SSR-first, Bun-native full-stack web framework.
Overview
@wrnexus/reactive is the seed of WRNexusJS's reactivity layer: a minimal signal primitive that holds a value, notifies subscribers when it changes, and hands back an unsubscribe function. It is deliberately small and framework-agnostic — it powers nothing on its own, but is shaped so client islands (and later the .wrn compiler's state blocks) can build reactive bindings on top of it. Reach for it when you need observable state without pulling in a full reactivity library.
bun add @wrnexus/reactive
Private package — the machine must be authenticated to the wrnexus npm org
(a read token in ~/.npmrc). Requires Bun (Node is not supported).
API
The package has a single entry point (.) exporting one function and three types.
signal<T>(initial: T): Signal<T>
Creates a reactive signal seeded with initial. Returns a Signal<T>:
| Member | Signature | Description |
|---|---|---|
get | (): T | Read the current value. |
set | (next: T): void | Write a new value. Subscribers run only when the value actually changes (compared with Object.is). |
update | (fn: (current: T) => T): void | Apply a function to the current value; equivalent to set(fn(get())). |
subscribe | (fn: Subscriber<T>): Unsubscribe | Register a subscriber; returns a function that removes it. |
Types
type Subscriber<T> = (value: T) => void;
type Unsubscribe = () => void;
interface Signal<T> {
get(): T;
set(next: T): void;
update(fn: (current: T) => T): void;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
Notes on semantics:
- No-op updates are skipped.
setcompares the incoming value to the current - Safe unsubscribe during notification. Subscribers are iterated over a copy of
one with Object.is; identical values do not notify subscribers.
the subscriber set, so a subscriber may call its own (or another's) unsubscribe while a notification is in flight.
Usage
import { signal } from "@wrnexus/reactive";
const count = signal(0);
count.get(); // 0
// Subscribe; the returned function unsubscribes.
const off = count.subscribe((value) => {
console.log("count is now", value);
});
count.set(1); // logs: count is now 1
count.set(1); // no-op — value unchanged, no notification
count.update((n) => n + 1); // logs: count is now 2
off(); // stop listening
count.set(3); // nothing logged
Typed signals infer T from the initial value, or can be annotated explicitly:
import { signal, type Signal } from "@wrnexus/reactive";
const user: Signal<{ name: string } | null> = signal(null);
user.set({ name: "Ada" });
Requirements / Notes
- Bun-only. Distributed as TypeScript source (
main/exportspoint at - Zero dependencies. The only runtime API used is the standard
Object.is. - Foundational primitive for WRNexusJS client islands and the forthcoming
.wrn
src/index.ts); consume it under Bun, which runs .ts directly.
compiler state blocks.
Complete TypeScript API
Generated from the exact installed package declarations.
/**
* Fine-grained reactive primitives shared by server utilities and client code.
* Updates are synchronous by default and coalesced inside `batch()`.
*/
type Subscriber<T> = (value: T, previous?: T) => void;
type Unsubscribe = () => void;
type Cleanup = () => void;
interface Signal<T> {
get(): T;
set(next: T): void;
update(fn: (current: T) => T): void;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
interface ReadonlySignal<T> {
get(): T;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
/** Coalesce every signal notification made by `fn` into one flush. */
declare function batch<T>(fn: () => T): T;
/** Read reactive values without recording dependencies. */
declare function untrack<T>(fn: () => T): T;
declare function signal<T>(initial: T): Signal<T>;
/**
* Run a dependency-tracked side effect. Dependencies are rebuilt after every
* execution, preventing stale subscriptions when conditional reads change.
*/
declare function effect(run: () => void | Cleanup): Cleanup;
/** Create a lazily readable derived signal with automatic dependency tracking. */
declare function computed<T>(read: () => T): ReadonlySignal<T>;
interface WatchOptions<T> {
immediate?: boolean;
equals?: (left: T, right: T) => boolean;
}
declare function watch<T>(read: () => T, listener: (value: T, previous: T | undefined) => void | Cleanup, options?: WatchOptions<T>): Cleanup;
type ResourceStatus = "idle" | "pending" | "success" | "error";
interface Resource<T> {
data: ReadonlySignal<T | undefined>;
error: ReadonlySignal<unknown>;
status: ReadonlySignal<ResourceStatus>;
loading: ReadonlySignal<boolean>;
run(): Promise<T | undefined>;
abort(reason?: unknown): void;
reset(): void;
}
interface ResourceOptions<T> {
initial?: T;
immediate?: boolean;
keepPrevious?: boolean;
}
declare function resource<T>(loader: (signal: AbortSignal) => Promise<T>, options?: ResourceOptions<T>): Resource<T>;
interface ReactiveScope {
add(cleanup: Cleanup): Cleanup;
dispose(): void;
readonly disposed: boolean;
}
declare function createScope(): ReactiveScope;
interface HistorySignal<T> extends Signal<T> {
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
clearHistory(): void;
}
declare function historySignal<T>(initial: T, options?: {
limit?: number;
equals?: (left: T, right: T) => boolean;
}): HistorySignal<T>;
interface UrlStateOptions<T> {
url?: URL;
parameter: string;
parse?: (value: string | null) => T;
serialize?: (value: T) => string | null;
replace?: (url: URL) => void;
}
declare function urlSignal<T>(initial: T, options: UrlStateOptions<T>): Signal<T>;
interface ReactiveContext<T> {
provide<R>(value: T, run: () => R): R;
use(): T;
}
declare function createContextProvider<T>(defaultValue?: T): ReactiveContext<T>;
declare function mountPortal(content: Node | string, target: Element): () => void;
declare function transition(update: () => void, options?: {
className?: string;
target?: Element;
durationMs?: number;
signal?: AbortSignal;
}): Promise<void>;
interface TimelineStep {
durationMs: number;
delayMs?: number;
easing?: (progress: number) => number;
update(progress: number): void;
}
interface AnimationTimeline {
play(options?: {
reverse?: boolean;
signal?: AbortSignal;
}): Promise<void>;
cancel(reason?: unknown): void;
readonly running: boolean;
}
declare function createTimeline(steps: TimelineStep[], options?: {
now?: () => number;
frame?: (callback: () => void) => unknown;
}): AnimationTimeline;
export { type AnimationTimeline, type Cleanup, type HistorySignal, type ReactiveContext, type ReactiveScope, type ReadonlySignal, type Resource, type ResourceOptions, type ResourceStatus, type Signal, type Subscriber, type TimelineStep, type Unsubscribe, type UrlStateOptions, type WatchOptions, batch, computed, createContextProvider, createScope, createTimeline, effect, historySignal, mountPortal, resource, signal, transition, untrack, urlSignal, watch };
Examples
Copy-ready examples from the installed package documentation.
Typical usage
import { signal } from "@wrnexus/reactive";
const count = signal(0);
count.get(); // 0
// Subscribe; the returned function unsubscribes.
const off = count.subscribe((value) => {
console.log("count is now", value);
});
count.set(1); // logs: count is now 1
count.set(1); // no-op — value unchanged, no notification
count.update((n) => n + 1); // logs: count is now 2
off(); // stop listening
count.set(3); // nothing loggedTyped signals infer T from the initial value, or can be annotated explicitly
import { signal, type Signal } from "@wrnexus/reactive";
const user: Signal<{ name: string } | null> = signal(null);
user.set({ name: "Ada" });