Skip to content

Low-Level API

Low-level APIs give you direct control over signal reactivity, mounting behavior, and internal structure. Use them when building custom utilities, framework integrations, or advanced reactive patterns.

onStart listens for when a mountable signal becomes active (first subscriber). Unlike onMount, which has debounced unmounting, onStart fires immediately without any delays.

import { signal, mountable, onStart, effect } from '@nano_kit/store'
const $data = mountable(signal(0))
onStart($data, () => {
console.log('Signal mounted') /* Fires immediately */
return () => {
console.log('Cleanup on unmount') /* Fires immediately on unmount */
}
})

onStop listens for when a mountable signal becomes inactive (last subscriber removed). Unlike onMount, which has debounced unmounting, onStop fires immediately without any delays.

import { signal, mountable, onStop } from '@nano_kit/store'
const $data = mountable(signal(0))
onStop($data, () => {
console.log('Signal unmounted')
})

onMounted provides a boolean signal tracking mount state. Unlike onMount, which has debounced unmounting, onMounted fires immediately without any delays.

import { signal, mountable, onMounted, effect } from '@nano_kit/store'
const $data = mountable(signal(0))
onMounted($data, (mounted) => {
console.log('Mounted:', mounted)
})
const stop = effect(() => $data())
/* Logs: Mounted: true */
stop()
/* Logs: Mounted: false */

noMount prevents subscribers created inside the function from triggering mount events on the specified signal. This is useful when you need to create effects that observe a mountable signal without activating its lifecycle.

import { signal, mountable, onMount, noMount, effect } from '@nano_kit/store'
const $data = mountable(signal(0))
onMount($data, () => {
console.log('Mounted')
})
/* Create effect that won't trigger $data mount */
const stop = noMount($data, () => effect(() => {
console.log('Value:', $data())
/* $data is observed but not mounted */
}))

morph creates a new signal that wraps an existing one with custom get/set behavior. The key feature is that you can dynamically change these behaviors on the fly by modifying this.get and this.set within the methods themselves.

import { signal, morph } from '@nano_kit/store'
const $source = signal(0)
const $doubled = morph($source, {
get() {
return this.source() * 2
},
set(value) {
this.source(value / 2)
}
})
$doubled(10)
console.log($source()) /* 5 */
console.log($doubled()) /* 10 */
/* You can change behavior on the fly */
const $dynamic = morph($source, {
get() {
/* Dynamically change get behavior */
if (this.source() > 10) {
this.get = () => this.source() * 3
}
return this.source()
}
})

nextValue resolves a signal setter value against the previous value. Use it when your low-level API accepts the same value | updater shape as writable signals.

import { nextValue } from '@nano_kit/store'
const prev = 1
nextValue(prev, 2) /* 2 */
nextValue(prev, value => value + 1) /* 2 */

signalNextValue resolves a signal setter value against a writable signal’s pending value. This is useful inside custom setters where several writes may happen during the same propagation cycle.

import { signal, morph, signalNextValue } from '@nano_kit/store'
const $count = signal(0)
const $positiveCount = morph($count, {
set(next) {
const value = signalNextValue(this.source, next)
this.source(Math.max(0, value))
}
})
$positiveCount(count => count + 1)

external creates a signal controlled by an external source. The factory function is called lazily on first read or write, allowing you to set up subscriptions to external data sources (WebSocket, DOM events, etc.) only when needed.

The factory receives the internal source signal and an ops object. You can optionally define ops.get and ops.set to customize how the external signal reads and writes. If either operation is not defined, external falls back to the internal source signal.

import { external, mountable, onStart, signalNextValue } from '@nano_kit/store'
/* Create signal synced with localStorage */
const $theme = external<string>(($theme, ops) => {
const sync = () => $theme(localStorage.getItem('theme') || 'light')
/* Initialize value */
sync()
/* Set up lifecycle */
onStart(mountable($theme), () => {
const handler = (event: StorageEvent) => {
if (event.key === 'theme') {
sync()
}
}
/* Update on mount */
sync()
/* Sync changes from other tabs */
window.addEventListener('storage', handler)
return () => window.removeEventListener('storage', handler)
})
/* Set custom setter that syncs to localStorage */
ops.set = (newValue) => {
const value = signalNextValue($theme, newValue)
$theme(value)
localStorage.setItem('theme', value)
}
})
/* Factory runs only on first access */
console.log($theme()) /* 'light' */
/* Custom setter is used */
$theme('dark') /* Updates localStorage and signal */

stored creates a writable signal backed by a storage adapter. The adapter only needs synchronous get, set, and del methods, so the same helper can wrap localStorage, cookies, in-memory maps, or any other synchronous key/value storage.

import { stored, JsonCodec, debounce } from '@nano_kit/store'
const storage = {
get(key: string) {
return localStorage.getItem(key)
},
set(key: string, value: string) {
localStorage.setItem(key, value)
},
del(key: string) {
localStorage.removeItem(key)
}
}
const $cart = stored(storage, 'cart', [] as number[] | null, JsonCodec, debounce(300))
$cart(items => [
...items,
42
])
$cart(null) /* Calls storage.del('cart') */

Assigning null or undefined deletes the storage entry by calling storage.del(key). If the signal has a default value, the signal value falls back to that default after deletion.

The storage adapter shape is intentionally small:

interface Storage<T> {
get(key: string): T | null
set(key: string, value: T): void
del(key: string): void
}

syncedStored uses the same storage adapter idea, but also listens for external changes through sub. The subscription starts only while the signal is active.

import { syncedStored, effect } from '@nano_kit/store'
const syncedStorage = {
get(key: string) {
return localStorage.getItem(key)
},
set(key: string, value: string) {
localStorage.setItem(key, value)
},
del(key: string) {
localStorage.removeItem(key)
},
sub(key: string, callback: (value: string | null) => void) {
const listener = (event: StorageEvent) => {
if (event.key === key && event.storageArea === localStorage) {
callback(event.newValue)
}
}
window.addEventListener('storage', listener)
return () => window.removeEventListener('storage', listener)
}
}
const $theme = syncedStored(syncedStorage, 'theme', 'light')
const stop = effect(() => {
document.documentElement.dataset.theme = $theme()
})

The synced storage adapter extends the base storage shape:

interface SyncedStorage<T> extends Storage<T> {
sub(key: string, callback: (value: T | null) => void): () => void
}

Both helpers support the same optional arguments: a default value, a codec, and a setter rate limiter for storage writes.

For both stored and syncedStored, assigning null or undefined uses del immediately instead of the setter rate limiter. Setter rate limiters only wrap set calls.

child creates a signal for a property of an object signal. For writable parent signals, returns a writable child. For readonly signals, returns a computed.

import { signal, child, assignKey } from '@nano_kit/store'
const $user = signal({ name: 'Dan', age: 30 })
/* Create writable child signal */
const $name = child($user, 'name', assignKey)
console.log($name()) /* Dan */
$name('Alice')
console.log($user()) /* { name: 'Alice', age: 30 } */

With dynamic keys:

import { signal, child, assignKey } from '@nano_kit/store'
const $obj = signal({ a: 1, b: 2 })
const $key = signal('a')
const $value = child($obj, $key, assignKey)
console.log($value()) /* 1 */
$key('b')
console.log($value()) /* 2 */

readonly marks a signal as read-only by removing the writable flag from its type and internal modes. This is important for public APIs where you want to expose state without allowing external modification.

import { signal, readonly } from '@nano_kit/store'
const $internalCount = signal(0)
const $count = readonly($internalCount)
/* Type error: cannot write to readonly signal */
// $count(1)
/* But you can still read */
console.log($count()) /* 0 */

When used with child, readonly signals create computed children instead of writable ones:

import { signal, readonly, child } from '@nano_kit/store'
const $user = signal({ name: 'Dan', age: 30 })
const $readonlyUser = readonly($user)
/* Creates a readonly computed, not a writable child */
const $name = child($readonlyUser, 'name')
/* Type error: cannot write */
// $name('Alice')

isMountable checks if a signal is mountable.

import { signal, mountable, isMountable } from '@nano_kit/store'
const $regular = signal(0)
const $mount = mountable(signal(0))
isMountable($regular) /* false */
isMountable($mount) /* true */

isWritable checks if a signal is writable.

import { signal, computed, readonly, isWritable } from '@nano_kit/store'
const $signal = signal(0)
const $computed = computed(() => 0)
const $readonly = readonly($signal)
isWritable($signal) /* true */
isWritable($computed) /* false */
isWritable($readonly) /* false */

deferScope defers effect creation until explicitly started. This allows you to prepare a scope of effects without running them immediately, giving you control over when the reactive logic begins execution.

import { deferScope, effect, signal } from '@nano_kit/store'
const $count = signal(0)
/* Create deferred scope - effects not started yet */
const start = deferScope(() => {
effect(() => {
console.log('Count:', $count())
})
effect(() => {
console.log('Double:', $count() * 2)
})
})
/* Effects won't run until start() is called */
$count(5)
/* Now start all effects in the scope */
const stop = start()
/* Logs: Count: 5, Double: 10 */
$count(10)
/* Logs: Count: 10, Double: 20 */
stop()
/* All effects stopped */