Signals
A signal is Udodi's lowest-level reactive primitive: a reactive cell that stores a value, tracks which reactive computations depend on it, and notifies those dependencies when the value changes.
Signals form the foundation of Udodi's reactivity system. Higher-level primitives such as reactive objects, computed values, effects, and template bindings build on the same dependency-tracking mechanism.
Creating a Signal
import { createSignal, effect } from "udodi";
const [count, setCount, triggerCount] = createSignal(0);createSignal(initialValue) returns a tuple of three functions:
| Function | Role |
|---|---|
| get | Returns the current value; registers the active effect as a subscriber |
| set | Performs an Object.is equality check before updating the value and notifying subscribers |
| trigger | Notifies subscribers without changing the stored value |
effect(() => {
console.log("count is", count());
});
setCount(1); // logs: count is 1
setCount(1); // no-op (same value)Reading
Call the getter to read the value:
const value = count();If a signal is read while an effect (or the internal effect of a computed) is active, that effect is added to the signal’s subscriber set. Later writes or triggers will schedule that effect to run again.
Reading outside any active effect does not create a subscription.
Writing
setCount(5);
setCount((/* not supported — pass the next value directly */));set:
- Compares the next value to the current one with
Object.is - Returns immediately if they are the same
- Otherwise stores the new value and notifies all subscribers
There is no updater-function form; pass the next value explicitly.
setCount(count() + 1);Triggering Without Changing the Value
The third tuple element is a manual trigger:
const [user, setUser, triggerUser] = createSignal({ name: "Ada" });
effect(() => {
console.log(user().name);
});
// In-place mutation — setUser is not called
user().name = "Grace";
// Notify dependents without replacing the object
triggerUser();Use trigger when:
- The signal holds an object or collection that was mutated in place
- You need dependents to re-run even though the reference did not change
For reactive objects created with reactive(), prefer touch(proxy, key) at the property level; trigger is the same idea at the raw signal level.
Equality
Updates use Object.is for equality:
| Comparison | Result |
|---|---|
Object.is(1, 1) | Is equal; do not notify |
Object.is(NaN, NaN) | Is equal; do not notify |
Object.is(0, -0) | Is not equal, then notify |
Object.is({}, {}) | Is not equal, then notify |
Replacing an object with a new reference always notifies, even if the contents are identical. Mutating an object in place does not notify unless you call trigger (or touch on a reactive property).
Signals and Effects
import { createSignal, effect } from "udodi";
const [first, setFirst] = createSignal("Ada");
const [last, setLast] = createSignal("Lovelace");
effect(() => {
console.log(`${first()} ${last()}`);
});
setFirst("Grace"); // effect re-runs
setLast("Hopper"); // effect re-runsEach run of the effect:
- Unsubscribes from the previous dependency set
- Runs the function (new reads re-subscribe)
- Leaves only the signals actually read on that run as dependencies
Conditional reads are therefore precise: a branch that is not taken does not subscribe.
Signals and Computed Values
Computed values use signals internally:
- An internal signal tracks consumers of the computed
- An internal effect recomputes when upstream dependencies change
- Consumers of the computed subscribe to the computed’s signal, not to every upstream signal
import { createSignal, computed, effect } from "udodi";
const [a, setA] = createSignal(1);
const [b, setB] = createSignal(2);
const sum = computed(() => a() + b());
effect(() => {
console.log("sum:", sum());
});
setA(3); // sum recomputes; effect runsReactive Objects Are Built From Signals
reactive() allocates one signal per reactive property. Property access goes through the signal getter; assignment goes through the signal setter (and optional interceptors).
// Conceptual model — not the public API
// state.count ≈ get()
// state.count = 1 ≈ set(1)You normally work with reactive() and component state() rather than raw signals, but understanding signals explains why shallow reactivity and touch() behave the way they do.
Lifecycle of a Subscription
effect runs
│
▼
getter() called
│
▼
effect added to signal.subscribers
effect.deps records that subscriber set
│
▼
set() / trigger()
│
▼
each subscriber scheduled (microtask)
│
▼
effect runs again
│
├── unsubscribes from previous deps
└── re-subscribes via new readsWhen an effect is disposed, it is removed from every subscriber set it was in, so it will not run again.
API Summary
const [get, set, trigger] = createSignal(initialValue);| Call | Behavior |
|---|---|
get() | Return value; track active effect |
set(next) | Update if not equal using Object.is; notify |
trigger() | Notify without changing the value |
Return type: [() => any, (next: any) => void, () => void]
When to Use Raw Signals
| Use case | Prefer |
|---|---|
| Component state | state() / reactive() |
| Derived values | computed() |
| Side effects tied to state | effect() or component watch |
| Standalone reactive cell outside components | createSignal |
| Manual notify after in-place mutation of a signal’s value | trigger |
Most application code stays at the reactive-object and component layers. Signals become useful when you need a single independent reactive cell, want to build an extension or library on top of Udodi's reactivity system, or need to understand how the higher-level reactive primitives are implemented.