Signals API

This document provides a detailed reference for the refui/signal module.

Core Functions

signal(value, compute?)

Creates a new signal.

const count = signal(0)
const derived = signal(count, (val) => val * 2)

Signal writes use !== change detection. Repeated NaN writes therefore trigger updates, while +0 and -0 are treated as equal.

computed(fn)

Creates a computed signal that derives its value from other signals.

const fullName = computed(() => `${firstName.value} ${lastName.value}`)

Computed signals are eager: their computation runs immediately and whenever a dependency changes. Create them inside a component, an EffectScope (for example, inside watch() or useEffect()), or a collectDisposers() scope when their lifetime is not application-global. A root-level computed remains connected while its reachable source graph retains it.

isSignal(value)

Checks if a value is a signal.

console.log(isSignal(count)) // true
console.log(isSignal(42)) // false

signal.ensure(value) / Signal.ensure(value)

Ensures a value is a signal. If the value is already a signal, it returns the signal unchanged. If not, it creates a new signal with that value.

const existingSignal = signal(42)
const newSignal = signal(100)

const ensured1 = signal.ensure(existingSignal) // Returns the same signal
const ensured2 = signal.ensure(50) // Creates a new signal(50)
const ensured3 = signal.ensure('hello') // Creates a new signal('hello')

console.log(ensured1 === existingSignal) // true
console.log(isSignal(ensured2)) // true

signal.ensureAll(...values) / Signal.ensureAll(...values)

Applies signal.ensure() to multiple values, returning an array of signals.

const mixed = [signal(1), 2, signal(3), 4]
const allSignals = signal.ensureAll(...mixed)
// Returns: [signal(1), signal(2), signal(3), signal(4)]

Signal Instance Methods

.get()

Gets the current value and registers the calling effect as a dependency.

const value = mySignal.get()

.set(value)

Sets a new value for the signal.

mySignal.set(42)

.peek()

Gets the current value without registering dependencies.

const value = mySignal.peek()

.poke(value)

Sets a value without triggering updates.

mySignal.poke(42)

.trigger()

Manually triggers updates for all connected effects.

mySignal.trigger()

.refresh()

Re-evaluates a computed signal's computation function and updates the signal if the result has changed. This method only works on computed signals (signals created with a computation function). For regular signals, this method has no effect.

This is useful when you need to manually force a computed signal to re-evaluate its computation, for example when external dependencies that aren't tracked by the signal system may have changed. Refresh reruns the computed signal's own effect scope, preserving its original input and collecting any newly reached signal dependencies without attaching them to the caller. If that computation was already queued, the synchronous refresh consumes the pending execution rather than running it twice.

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

// Manually refresh the computed signal
doubled.refresh()

// Example with external dependency
let externalValue = 10
const computed = signal(null, () => count.value + externalValue)

// Later, when externalValue changes outside the signal system
externalValue = 20
computed.refresh() // Force re-evaluation with new externalValue

.connect(effect, runImmediate = true)

Manually connects an effect to the signal.

This is an explicit connection: signal reads inside effect do not add dependencies. Use watch() or useEffect() when the callback should automatically subscribe to every signal it reads.

const dispose = mySignal.connect(() => console.log('Signal changed'))

// Connect without running immediately
const disposeDeferred = mySignal.connect(() => console.log('Signal changed'), false)

dispose()
disposeDeferred()

.touch()

Subscribes the current effect to this signal without reading its value. This is useful when you want to trigger an effect when a signal changes, but you don't need its value inside the effect.

mySignal.touch()

Signal Properties

.value

Getter/setter for the signal's value.

mySignal.value = 42
console.log(mySignal.value)

.connected

Boolean indicating if the signal has any connected effects.

console.log(mySignal.connected) // true/false

.hasValue()

Checks if the signal has a non-nullish value (not undefined or null).

const name = signal('John')
const empty = signal(null)

console.log(name.hasValue()) // Should return true
console.log(empty.hasValue()) // Should return false

.nullishThen(value)

Returns a new signal that provides a fallback value when the current signal is nullish (undefined or null). This is similar to the nullish coalescing operator (??) but for signals.

const username = signal(null)
const defaultName = username.nullishThen('Anonymous')

console.log(defaultName.value) // 'Anonymous'

username.value = 'Alice'
// defaultName will reactively update to 'Alice'

username.value = undefined
// defaultName will reactively update back to 'Anonymous'

.choose(trueValue, falseValue)

Creates a derived signal that resolves to trueValue when the source signal is truthy and falseValue otherwise.

const isDarkMode = signal(false)
const themeAsset = isDarkMode.choose('dark.css', 'light.css')

console.log(themeAsset.value) // 'light.css'

isDarkMode.value = true
nextTick(() => {
	console.log(themeAsset.value) // 'dark.css'
})

Signal Operations

Signals support various comparison and logical operations:

.inverse()

Returns a signal that negates the current signal's value.

const isEnabled = signal(true)
const isDisabled = isEnabled.inverse() // !isEnabled.value

.and(value), .or(value)

Basic logical operations.

const isPositive = count.gt(0)
const isValid = isPositive.and(isEnabled)
const hasValueOrDefault = value.or(defaultValue)

.andNot(value), .orNot(value)

Logical operations with negated second operand.

const isPositiveAndNotZero = count.andNot(count.eq(0)) // count > 0 && !(count === 0)
const isValidOrNotDisabled = isValid.orNot(isDisabled) // isValid || !isDisabled

.andOr(andValue, orValue)

Combines logical AND and OR in a single helper, equivalent to (source && andValue) || orValue.

const isAuthenticated = signal(false)
const userDisplay = signal('Guest')
const fallbackName = 'Anonymous'

const currentUser = isAuthenticated.andOr(userDisplay, fallbackName)

console.log(currentUser.value) // 'Anonymous'

isAuthenticated.value = true
nextTick(() => {
	console.log(currentUser.value) // 'Guest'
})

.inverseAnd(value), .inverseOr(value)

Logical operations with negated first operand (the signal itself).

const isInactiveAndVisible = isActive.inverseAnd(isVisible) // !isActive && isVisible
const isInactiveOrVisible = isActive.inverseOr(isVisible) // !isActive || isVisible

.inverseAndNot(value), .inverseOrNot(value)

Logical operations with both operands negated.

const isInactiveAndHidden = isActive.inverseAndNot(isVisible) // !isActive && !isVisible
const isInactiveOrHidden = isActive.inverseOrNot(isVisible) // !isActive || !isVisible

.inverseAndOr(andValue, orValue)

Variant of .andOr that negates the base signal first, equivalent to (!source && andValue) || orValue.

const isOffline = signal(true)
const reconnectHint = 'Tap to reconnect'
const onlineMessage = signal('You are online')

const bannerText = isOffline.inverseAndOr(reconnectHint, onlineMessage)

console.log(bannerText.value) // 'Tap to reconnect'

isOffline.value = false
nextTick(() => {
	console.log(bannerText.value) // 'You are online'
})

.select(options)

Creates a derived signal that treats the current value as a lookup key inside options.

const status = signal('idle')
const messages = signal({
	idle: 'Waiting…',
	success: 'All good!',
	error: 'Something went wrong!'
})

const statusMessage = status.select(messages)

console.log(statusMessage.value) // 'Waiting…'

status.value = 'success'
nextTick(() => {
	console.log(statusMessage.value) // 'All good!'
})

messages.value = { idle: 'Ready when you are!' }
nextTick(() => {
	console.log(statusMessage.value) // undefined
})

.eq(value), .neq(value)

Equality comparisons.

const isZero = count.eq(0)
const isNotZero = count.neq(0)

.gt(value), .lt(value), .gte(value), .lte(value)

Numeric comparisons.

const isPositive = count.gt(0)
const isNegative = count.lt(0)
const isNonNegative = count.gte(0)
const isNonPositive = count.lte(0)

Utility Functions

read(value)

Reads a value, safe for the value to be a signal or not.

const result = read(someValue) // Works with signals or regular values

peek(value)

Peeks at a value without creating dependencies.

const result = peek(someSignal)

write(signal, newValue)

Writes to a signal or applies a function. Has no effect if the value to be written is not a signal.

write(count, 42)
write(count, prev => prev + 1)

readAll(...values)

Reads all values and return an array of plain values.

const [val1, val2] = readAll(signal1, signal2)

poke(signal, newValue)

Pokes a value into a signal, same as signal.poke(newValue). Has no effect if the value to be written is not a signal.

poke(count, 42)

touch(...values)

Touches a list of signals to register a dependency. Has no effect if the value is not a signal.

touch(someValue, someOtherValue) // Works with signals or regular values

Effect Management

EffectScope

Low-level lifecycle and reactive-effect scope. Constructing a scope does not run its effect; call run() to execute it and collect signal dependencies. A cleanup function returned by the effect runs before the next execution and when destroy() is called. Cleanup before a rerun retains the effect as its lifecycle owner, while all cleanup runs have dependency collection disabled so teardown-only signal reads do not make an effect reactive to those signals.

Effects and disposal callbacks created while the scope is running belong to it. Destroying the scope releases those children and makes later queued runs inert. Component instances extend EffectScope, so component-owned effects use the same lifecycle representation.

const scope = new EffectScope(() => {
	console.log(count.value)
	return () => console.log('cleanup')
})

scope.run()
scope.destroy()

watch(effect)

Creates an effect that runs when dependencies change.

Signals reached on later runs are tracked as well, so short-circuited expressions such as a.value && b.value begin listening to b after a becomes truthy. If later control flow stops reading a dependency, its existing subscription may cause one final run when that signal changes; because the effect does not read it again, that stale subscription is then retired.

Each run is also a lifecycle scope. Child effects and onDispose callbacks created during a run are cleaned before the next run and when the parent watch is disposed. Deliberately long-lived work should be created outside the rerunning watch or placed in a separately managed detached scope.

If an execution throws, the effect is destroyed after its registered cleanup finishes. This prevents a failed rerun from remaining active with a partially collected dependency graph; create a new effect explicitly if application error handling should retry it.

const dispose = watch(() => {
	console.log('Value:', mySignal.value)
})

connect(signals, effect, runImmediate = true)

Connects multiple signals to an effect.

Only the supplied signals schedule the callback. Reads performed inside effect are untracked; use watch() or useEffect() for automatic dependency discovery.

const dispose = connect([signal1, signal2], () => {
	console.log('Signals changed')
})

// Connect without running immediately
const disposeDeferred = connect([signal1, signal2], () => {
	console.log('Signals changed')
}, false)

dispose()
disposeDeferred()

bind(handler, value)

Binds a handler to a value (signal, function, or static value).

bind(console.log, mySignal)

listen(signals, callback)

Listens to multiple signals with a single callback.

Returns one function that disposes every connection created by the call. Registration is transactional: because each signal invokes the callback eagerly, a callback failure during setup disposes every connection that was already created before rethrowing the error.

const dispose = listen([signal1, signal2], () => {
	console.log('One of the signals changed')
})

dispose()

useAction(value?, compute?)

Creates an action system with an event handler and trigger function. This is useful for setting up lightweight event buses or responding to imperative notifications without wiring an external emitter.

import { useAction, useEffect } from 'refui'

const [onSubmit, triggerSubmit, touchSubmit] = useAction({ status: 'idle' })

let ignoreUpdates = false
onSubmit((payload) => {
	if (ignoreUpdates) return
	console.log('Action payload:', payload)
})

// Batch safe: only the last trigger inside a tick notifies listeners
triggerSubmit({ status: 'loading' })
triggerSubmit({ status: 'done' })

// Mark an effect as interested in future triggers without re-registering listeners
useEffect(() => {
	touchSubmit()
	console.log('submit changed')
	return () => {
		// scope disposal automatically unregisters listeners added above
	}
})

ignoreUpdates = true // guard future callbacks without adding/removing listeners

Avoid overusing useAction; the built-in signal graph already covers most reactive flows. Reach for actions only when you need an imperative bridge and keep the listener graph shallow to maintain predictability.

Advanced Signal Operations

merge(signals, handler)

Merges multiple signals into a computed signal.

const fullName = merge([firstName, lastName], (first, last) => `${first} ${last}`)

tpl(raw, ...expressions)

Creates a template string signal using String.raw.

Use it as a tagged template literal most of the time. You can also pass a single raw string segment (no expressions) or a raw strings array generated programmatically (make sure the array has one more entry than the number of expressions).

const message = tpl`Hello ${name}, you have ${count} items`

derive(signal, key, compute?)

Creates a derived signal from an object property. The derived signal's content updates when the original signal changes, or when the corresponding property is itself a signal. Property propagation is treated as derived work and settles before user effects run. A nullish source clears the derived value to undefined. Writing the derived signal does not update the original signal's property.

const name = derive(user, 'name')

extract(signal, ...keys)

Extracts properties from a signal into separate signals. The extracted signals update only when the original signal changes. Writing the extracted signals does not update the original signal's properties.

const { name, age } = extract(user, 'name', 'age')

derivedExtract(signal, ...keys)

Similar to extract but creates derived signals.

const { name, age } = derivedExtract(user, 'name', 'age')

makeReactive(object)

Creates a new accessor-backed object. Properties whose input values are signals become getters and setters that read and write those signals; non-signal properties are copied as plain values. This helper does not create a JavaScript Proxy and does not make nested plain objects reactive.

const reactive = makeReactive({
	count: signal(0),
	name: 'John'
})

Conditional Logic

onCondition(signal, compute?)

Creates conditional matching based on signal values. When the match condition is itself a signal, its match state settles as derived work before user effects run.

const stateMatch = onCondition(state)
const isLoading = stateMatch('loading')
const isError = stateMatch('error')

Lifecycle Management

onDispose(callback)

Registers a cleanup callback.

onDispose(() => {
	console.log('Cleaning up')
})

useEffect(effect, ...args)

Registers an effect that runs automatically and handles its own cleanup. The effect function is executed immediately and re-executed whenever its signal dependencies change. Similar to watch, there is no option to skip the initial run—use nextTick or connect if you need to defer invocation.

If the effect function returns another function, that returned function will be used as a cleanup handler. The cleanup is called right before the effect re-runs, and also when the component/scope is disposed. Signal reads performed only by cleanup do not become dependencies of the effect.

Any additional arguments passed to useEffect after the effect function will be passed along to the effect function when it's called.

// Example 1: Basic side effect with cleanup
const interval = signal(1000)
useEffect(() => {
	const timer = setInterval(() => {
		console.log('Timer tick')
	}, interval)

	// Cleanup function
	return () => {
		console.log('Clearing timer')
		clearInterval(timer)
	}
})

// Will stop the previous timer and restart a new timer with the interval 2000
interval.value = 2000

// Example 2: Effect with dependencies
const count = signal(0)
useEffect(() => {
	console.log(`The count is: ${count.value}`)

	// This effect has a dependency on `count`.
	// It will re-run whenever `count.value` changes.
})

// Example 3: Passing arguments to an effect
const name = signal('Alice')

function logName(user) {
		console.log(`Current user: ${user.value}`)
}

useEffect(logName, name)

// Later...
name.value = 'Bob' // Will trigger the effect and log "Current user: Bob"

collectDisposers(fn, cleanup?, disposers?, ownerDisposers?)

Collects disposers created while fn runs and returns a function that disposes the collected scope. The optional cleanup runs before the collected child disposers, and an existing DisposerStore can be supplied as the third argument. The optional fourth argument supplies an owner store; pass null only for a deliberately detached scope whose lifetime is managed manually. Treat supplied stores as opaque: do not depend on their length, ordering, or entries.

const dispose = collectDisposers(() => {
	// Create effects here
})

Control Flow

untrack(fn, ...args)

Runs a function outside the current reactive scope. Arguments are passed as-is to the fn. Signal reads do not create dependencies, and effects or disposal callbacks created inside the function are detached from the caller's lifecycle ownership.

const result = untrack(() => {
	return someSignal.value // Won't create dependency
})

Use a separately managed scope when detached work needs an explicit lifetime.

freeze(fn)

Locks in the current effect scope for later use. The returned wrapper keeps referencing the original scope—much like an AsyncContext scope—so any effects you create inside it attach to the cleanup bucket that existed at wrap time. If an effect was running when you called freeze, subsequent executions will tie freshly accessed signals back to that same effect. Once the owning scope is disposed, the wrapper still executes under that inactive scope; new effects become inert and stop tracking changes within it.

const runLater = freeze((message) => {
	console.log('Tracked with original context:', message.value)
})

setTimeout(() => {
	runLater('hello from the future')
}, 1000)

scopeValid(scope?)

Returns whether the supplied EffectScope, or the currently executing scope when omitted, is active and valid. Because freeze() captures the current scope directly, destroying that scope invalidates later calls without a separate context-validity flag.

Scheduling

tick()

Triggers the next tick of the scheduler.

tick().then(() => {
	console.log('Updates applied')
})

nextTick(callback, ...args)

Waits for the next tick and executes a callback after all pending signal updates and effects have been processed. Returns a Promise that resolves after the callback completes.

This is essential when you need to access updated computed signal values after making changes, since signal effects are processed asynchronously.

If one or more queued effects throw, the failing effects are destroyed, while the scheduler continues draining unrelated runnable work before rejecting. One failure is rethrown directly; multiple failures are reported as an AggregateError.

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

count.value = 5

// Without nextTick - might still see old value
console.log(doubled.value) // Could be 0 (old value)

// With nextTick - guaranteed to see updated value
nextTick(() => {
	console.log(doubled.value) // Will be 10 (updated value)
})

// With additional arguments
const logValue = (prefix, signal) => {
	console.log(prefix, signal.value)
}

nextTick(logValue, 'Doubled:', doubled)

// Can also be used with async/await
await nextTick(() => {
	console.log('All updates processed')
})

Special Signal Behaviors

Signals have some special behaviors when used in certain contexts, thanks to toJSON, Symbol.toPrimitive, and Symbol.iterator implementations.

JSON.stringify(signal)

When a signal is stringified using JSON.stringify, it automatically returns its value by calling .get().

const data = signal({ a: 1 })
JSON.stringify({ data }) // '{"data":{"a":1}}'

Coercion

Signals can be automatically coerced to primitives, which calls .get().

const count = signal(5)
console.log(count + 5) // 10
console.log(`${count}`) // "5"
if (count) { /* ... */ } // true if count.value is truthy

Iteration

If a signal contains an iterable, it can be used in a for...of loop or with the spread syntax, which calls .get().

const items = signal([1, 2, 3])
for (const item of items) {
	console.log(item)
}
// 1
// 2
// 3

const spreadItems = [...items] // [1, 2, 3]