@praxisjs/core/internal
Signal primitives (signal, computed, effect, batch, peek, selector), persistence, reactive utilities (when, until, debounced, history), resource (cache, SWR, dedup, invalidation), and RootComponent.
@praxisjs/core/internal
import { signal, effect, history, RootComponent } from '@praxisjs/core/internal'npm install @praxisjs/corepnpm add @praxisjs/coreyarn add @praxisjs/corebun add @praxisjs/coreAll signal primitives, reactive utilities, resource, and the component base class live here. This is the building block path — use it inside decorator and composable implementations that need direct access to the reactive engine.
Signals
signal<T>(initialValue)
Creates a reactive signal — a callable that returns its current value and notifies subscribers when it changes.
function signal<T>(initialValue: T): Signal<T>const count = signal(0)
count() // 0 — read
count.set(1) // write
count.update(n => n + 1) // update from previousSubscribers registered via effect or subscribe are notified synchronously on every .set() call unless inside a batch. Uses identity comparison (Object.is) — setting the same value is a no-op.
Signal<T> interface
| Member | Description |
|---|---|
() | Read the current value. Registers the active effect as a subscriber. |
.set(value) | Write a new value and notify subscribers. |
.update(fn) | Write fn(currentValue). |
.subscribe(fn) | Subscribe to changes. Calls fn immediately with the current value. Returns an unsubscribe function. |
computed<T>(getter)
Creates a lazy derived value that recomputes only when its signal dependencies change.
function computed<T>(getter: () => T): Computed<T>const doubled = computed(() => count() * 2)
doubled() // reads and caches; re-runs only if count changedSignals, computeds and effects form a push-pull graph. A write marks the direct subscribers stale and the ones behind a computed maybe stale; a computed then recomputes only when one of its sources actually changed, and a computed that recomputes to the same value (Object.is) does not wake what depends on it. Dependencies are stored as linked nodes that a re-run reuses in place, so a binding that reads the same sources every time allocates nothing and never resubscribes.
Effects that read a changed signal directly re-run synchronously. Effects that depend on a change only through a computed re-run once per tick, in a microtask, with the final values of every write made in the same synchronous block. Either way an effect pulls fresh values when it runs, so it never sees a computed out of step with its sources, and a computed that reads itself throws a cycle error instead of looping.
Computed<T> interface
| Member | Description |
|---|---|
() | Read the cached value. Recomputes if dirty. |
.subscribe(fn) | Subscribe to changes. Returns an unsubscribe function. |
effect(fn)
Runs fn immediately and re-runs it whenever any signal read inside fn changes.
function effect(fn: () => (() => void) | void): () => voidconst stop = effect(() => {
console.log(count())
return () => { /* cleanup on each re-run */ }
})
stop() // stop tracking and run final cleanupThe returned function stops the effect and runs cleanup. A stopped flag prevents re-runs after stop() is called — subscribing to a signal after stopping is safe.
untrack<T>(fn)
Runs fn without registering any signal subscriptions.
function untrack<T>(fn: () => T): Teffect(() => {
const a = a$() // subscribed
const b = untrack(() => b$()) // not subscribed
return a + b
})Used internally to wrap mountComponent so that static reads inside render() never create accidental subscriptions.
batch(fn)
Defers all subscriber notifications until fn completes, then flushes them once.
function batch(fn: () => void): voidbatch(() => {
x.set(1)
y.set(2)
// subscribers notified once here, not twice
})Supports nesting — the flush happens only when the outermost batch exits. Each effect runs at most once per batch regardless of how many of its dependencies changed.
peek<T>(source)
Reads a signal or computed without subscribing the active effect to it.
function peek<T>(source: Signal<T> | Computed<T>): Teffect(() => {
if (flag()) {
const val = peek(expensive) // read without subscribing
}
})selector<T, K>(source, equals?)
Turns a source into a per-key boolean check whose subscribers are bucketed by key. When the source changes, only the effects that read an affected key are notified. Backs the @Selector decorator.
type Selector<K> = (key: K) => boolean
type SelectorEquals<K, T> = (key: K, value: T) => boolean
function selector<T>(source: () => T): Selector<T>
function selector<T, K>(source: () => T, equals: SelectorEquals<K, T>): Selector<K>const selected = signal(1)
const isSelected = selector(() => selected())
effect(() => { row1.className = isSelected(1) ? 'on' : '' })
effect(() => { row2.className = isSelected(2) ? 'on' : '' })
selected.set(2) // re-runs both effects; a third key's effect would not run- Default comparison — SameValueZero, the rule
Mapuses for keys. A change looks up the buckets of the previous and next value directly, so it costs O(1) regardless of how many keys are read. - Custom
equals— every key in use is checked against the previous and next value, and only buckets whose result changed are notified. - Notification — affected buckets are flushed in one
batch, so an effect reading several affected keys re-runs once. - Lifetime — a key's bucket is dropped when its last effect re-runs or stops. The source is read inside an internal effect that starts with the first tracked read and stops a microtask after the last bucket is gone.
- Untracked reads —
isSelected(key)outside an effect returns the comparison without subscribing. It reads the source directly when no effect is subscribed, or when the source changed and its subscribers have not been notified yet (inside abatch, an event handler, or a microtask after a computed source changed), so it always reflects the current source value.
persistedSignal<T>(key, initialValue, options?)
A signal backed by localStorage. Reads the stored value on creation, writes back on every .set().
function persistedSignal<T>(
key: string,
initialValue: T,
options?: PersistedSignalOptions<T>
): Signal<T>const theme = persistedSignal('theme', 'light', { syncTabs: true })With syncTabs: true (default), a storage event listener keeps the signal in sync across browser tabs.
PersistedSignalOptions<T>
| Option | Default | Description |
|---|---|---|
serialize | JSON.stringify | Converts the value to a string for storage. |
deserialize | JSON.parse | Parses the stored string back to T. |
syncTabs | true | Syncs changes across tabs via the storage event. |
syncedSignal<T>(channelName, initialValue)
A signal that stays in sync across browser tabs via BroadcastChannel. Writing in any tab broadcasts to all others.
function syncedSignal<T>(channelName: string, initialValue: T): SyncedSignal<T>const cursor = syncedSignal('cursor-pos', { x: 0, y: 0 })
cursor.set({ x: 10, y: 20 }) // broadcast to other tabs
cursor.close() // close the BroadcastChannelSyncedSignal<T> extends Signal<T> with a .close() method that shuts down the underlying BroadcastChannel.
Reactive utilities
when(source, fn)
Runs fn once the next time source() is truthy. Cleans up the internal effect immediately after firing.
function when<T>(
source: Signal<T> | Computed<T>,
fn: (value: NonNullable<T>) => void
): () => voidconst stop = when(isReady, () => console.log('mounted'))
stop() // cancel before it firesReturns a cancel function. If the source is already truthy on the first run, fn fires synchronously and the effect is stopped.
until(source)
Returns a Promise that resolves with the signal's value the next time it becomes truthy.
function until<T>(source: Signal<T> | Computed<T>): Promise<NonNullable<T>>const data = await until(resource.data)Built on top of when. The promise never rejects.
debounced(source, ms)
Returns a new signal whose value updates only after ms milliseconds of silence from source.
function debounced<T>(
source: Signal<T> | Computed<T>,
ms: number
): Signal<T> & { stop(): void }const lazyQuery = debounced(searchInput, 300)
effect(() => fetchResults(lazyQuery()))The returned signal has a .stop() method that cancels the internal effect and any pending timer.
history(source, limit?)
Tracks the value history of a signal with undo/redo support.
function history<T>(
source: Signal<T> | Computed<T>,
limit?: number // default: 50
): HistoryElement<T>const h = history(count)
h.current() // Computed<T> — current value
h.values() // Computed<T[]> — past + current
h.canUndo() // Computed<boolean>
h.canRedo() // Computed<boolean>
h.undo() // step back
h.redo() // step forward
h.clear() // wipe past and futureWhen source is a Signal (not just Computed), undo() and redo() also write back to the source signal.
HistoryElement<T>
| Member | Type | Description |
|---|---|---|
values | Computed<T[]> | All past values plus the current one. |
current | Computed<T> | The current value. |
canUndo | Computed<boolean> | Whether there are past entries to step back to. |
canRedo | Computed<boolean> | Whether there are future entries to step forward to. |
undo() | void | Move one step back in history. |
redo() | void | Move one step forward in history. |
clear() | void | Clear past and future, keeping the current value. |
Resource
resource(fetcher, options?)
Creates a reactive async resource. Re-runs fetcher whenever any signal read inside it changes.
function resource<T>(
fetcher: () => Promise<T>,
options?: ResourceOptions<T>
): Resource<T>const posts = resource(() => fetch(`/api/posts?page=${page()}`).then(r => r.json()))
posts.data() // Computed<T | null>
posts.pending() // Computed<boolean>
posts.error() // Computed<unknown>
posts.status() // Computed<ResourceStatus>
posts.refetch() // re-run manually
posts.cancel() // abort the in-flight fetch (idempotent)
posts.mutate(v) // set data optimistically
posts.destroy() // stop effects, remove focus listener, unregister from cacheResourceOptions<T>
| Option | Default | Description |
|---|---|---|
initialData | null | Value of data before the first successful fetch. |
immediate | true | Run the fetcher immediately on creation. |
keepPreviousData | false | Keep the last successful data while re-fetching instead of resetting to null. |
key | — | Cache/deduplication key. All resources sharing the same key reuse in-flight requests and read from a single cache entry. Designed for non-reactive fetchers; include reactive params in the key string when needed. |
staleTime | 0 | How long (ms) a cached entry is considered fresh. While fresh, the initial fetch is skipped entirely. 0 = always stale (SWR: shows cached data, fetches in background). Infinity = never refetch from cache. |
refetchOnFocus | false | Refetches when document.visibilityState becomes "visible". No-op in non-browser environments. |
Resource<T> interface
| Member | Description |
|---|---|
data | Computed<T | null> — resolved value or null. |
pending | Computed<boolean> — true while in-flight. |
error | Computed<Error | null> — last error or null. |
status | Computed<ResourceStatus> — "idle" | "pending" | "success" | "error". |
refetch() | Re-runs the fetcher. Uses deduplication when key is set. |
cancel() | Abandons the in-flight request; resets status to "idle". |
mutate(v) | Sets data directly (optimistic update) without fetching. |
destroy() | Stops the reactive effect, removes the visibilitychange listener, and unregisters from the cache registry. Called automatically by @Resource on component unmount. |
ResourceStatus — "idle" | "pending" | "success" | "error"
createResource(param, fetcher, options?)
Like resource, but re-runs fetcher whenever the reactive param signal changes, passing the current param value as the argument.
function createResource<P, T>(
param: Signal<P> | Computed<P>,
fetcher: (param: P) => Promise<T>,
options?: ResourceOptions<T>
): Resource<T>const post = createResource(postId, (id) => fetchPost(id))Used internally by @Resource when a reactive source is provided.
invalidateResource(key)
Clears the cache entry for key, cancels any stored in-flight request, and triggers an immediate refetch on every active resource registered under that key.
function invalidateResource(key: string): voidimport { invalidateResource } from '@praxisjs/core/internal'
invalidateResource('current-user')
// → cache cleared; all resource() instances with key: 'current-user' refetchNo-op when key has no registered resources or no cache entry.
_clearCache()
Resets all cache state (cache entries, in-flight map, and resource registry). Intended for use in tests only — call it in beforeEach to prevent state leaking between test cases.
function _clearCache(): voidimport { _clearCache } from '@praxisjs/core/internal'
beforeEach(() => { _clearCache() })Component
RootComponent
Abstract base class that every PraxisJS component ultimately extends. The renderer instantiates components via new Ctor(props) and expects this shape.
abstract class RootComponent<T extends object = Record<string, never>> {
readonly _rawProps: T
_mounted: boolean
_anchor?: Node
get props(): T
onBeforeMount?(): void
onMount?(): void
onUnmount?(): void
onError?(error: Error): void
abstract render(): Node | Node[] | null
}| Member | Description |
|---|---|
_rawProps | Props filled by the renderer at mount time. Read through this.props in user code. |
_mounted | Set to true after onMount fires. Used by lifecycle decorators to guard callbacks. |
_anchor | A node of the component's output (read with getComponentAnchor()): its root node when it renders exactly one, otherwise the comment the renderer places after its nodes. Decorators use its parentElement to locate the component's container. |
render() | Must be implemented. Called exactly once on mount — never on updates. |
Internal APIs
Low-level primitives exposed for building decorators and composables — signals, effects, resource, type guards, and RootComponent.
@praxisjs/shared/internal
Runtime type guards (isSignal, isComputed, isComponent), flattenChildren, and TypeScript interfaces (BaseReactive, ComponentConstructor, ComponentInstance) shared across packages.