Reactive signal library without any dependencies. (In progress)
-
signalContains the source values that can be modified.
-
derived/computedUses one or more
signaland/orderivedvalues to calculate a newderivedvalue. -
effectUses one or more
signaland/orderivedvalues to act as triggers on other parts of your system.
-
Fixed Dependencies
deriveds andeffects can take their dependencies up front and get their values as arguments in the callback.const [s1, s2, s3] = signals(1, 2, 3); const d = derived(s1, s2, s3, (x, y, x) => x + y z); const c = computed(s1, s2, s3, (x, y, x) => x + y z); const e = effect(s1, s2, s3, (x, y, x) => do(x + y z));
-
Dynamic Dependencies
deriveds andeffects can use dependencies directly in the callback. This is the most common way of using signals.const [s1, s2, s3] = signals(1, 2, 3); const d = derived(() => s1() + s2() + s3()); const c = computed(() => s1() + s2() + s3()); const e = effect(() => do(s1() + s2() + s3()));
-
Readonly Wrapping
signals can be exposed to consumers as a readonly value.const s = signal(false); s(); //get ok s(true); //set ok const r = readonly(s); r(); //get ok r(false); //throws error
-
Object Properties
signals andderiveds can be attached as properties to an object. Allows normal get/set coding.const dialog = {}; propup(dialog, "name", signal("")); dialog.name = "Douglas"; //setter const n = dialog.name; //getter
-
Object/Array Modification
signals allow internal modification of objects and arrays. Reduces cost of changing complex types.const author = signal({ name: "Douglas", surname: "" }); modify(author, x => x.surname = "Adams");
-
Reentry Prevention
Will throw error if a loop is detected, or if a
derivedcalculation try to write tosignals.effects are allowed to write back to the signals again. This allows value feedback loops, a scenario that can trigger recursion. This is prevented by snapshotting. -
GC Friendly
The library does not store any of the primitives in a global bookkeeping structure. Remove your reference to a primitive and it will (eventually) be collected.
Remember though, dependency references in
derivedandeffectstill apply. The top nodes need to be unreferenced for the underlying structure to be collected.GC is unpredictable. With execution strategies other than the default Manual, it is necessary to drop the primitives first to prevent unintended execution.
-
Manual
(default) Will not execute
deriveds andeffects whensignals change. Either call each primitive directly or use the update method on an array of them. -
Immediate
Will execute dependent
deriveds andeffects immediately when a signal is set. -
Delayed
Gathers all affected
deriveds andeffects whensignals change. Execute them at a convenient time with the update method. -
Custom
Roll your own strategy. Implement the ExecutionHandler interface, it notifies each
signalchange and provides thederiveds andeffects that directly depend on it.
const canAccept = signal(false);
console.log(canAccept()); // outputs 'false'
canAccept(true);
console.log(canAccept()); // outputs 'true'const canAccept = signal(false);
const showAccept = readonly(canAccept);
console.log(showAccept()); // outputs 'false'
//showAccept(true); // throws error
console.log(showAccept()); // outputs 'false'
canAccept(true);
console.log(showAccept()); // outputs 'true'const dialog = {};
propup(dialog, "name", signal(""));
dialog.name = "Douglas";
console.log(dialog.name); // outputs 'Douglas'
const canAccept = signal(false);
propup(dialog, "canAccept", readonly(canAccept));
console.log(dialog.canAccept); // outputs 'false'
dialog.canAccept = true; // throws error, does not contain setterconst author = signal({ name: "Douglas", surname: "" });
const fullname = derived(author, x => (x.name + " " + x.surname).trim() );
console.log(fullname()); // outputs 'Douglas'
modify(author, x => x.surname = "Adams");
console.log(fullname()); // outputs 'Douglas Adams'const name = signal("Douglas");
const surname = signal("");
const fullname = derived(name, surname, (n, s) => [n,s].join(" ").trim());
console.log(fullname()); // outputs 'Douglas'
surname("Adams");
console.log(fullname()); // outputs 'Douglas Adams'
// derived can also rely on other derived signals
const uppercase = derived(fullname, (f) => f.toUpperCase());
console.log(uppercase()); // outputs 'DOUGLAS ADAMS'
// and it cannot be written to
uppercase("DA"); // throws error, does not contain setterconst a = signal(1);
const b = signal(2);
const c = derived(a, b, (x, y) => 2 * x + y);
const log = effect(c, (x) => console.log(x));
log(); // outputs '4'
log(); // no output, dependent signals are unchanged
a(20);
log(); // outputs '42'execution.handler = ImmediateExecution;
const a = signal(1);
const b = signal(2);
const c = derived(a, b, (x, y) => 2 * x + y);
effect(c, (x) => console.log(x)); // outputs '4'
a(20); // outputs '42'const handler = DelayedExecution;
execution.handler = handler;
const a = signal(1);
const b = signal(2);
const c = derived(a, b, (x, y) => 2 * x + y);
effect(c, (x) => console.log(x));
update(); // outputs '4'
a(20);
update(); // outputs '42'Test based on cellx benchmark.
Average of 10 iterations. Results in milliseconds.
| handler\layers | 10 | 100 | 500 | 1000 | 5000 |
|---|---|---|---|---|---|
| manual (f) | 0.10 | 0.77 | 3.38 | 4.82 | 36.77 |
| immediate (f) | 0.11 | 1.17 | 5.89 | 11.86 | 66.33 |
| delayed (f) | 0.07 | 0.54 | 3.22 | 6.64 | 35.53 |
| manual (d) | 0.11 | 0.99 | 3.94 | 7.48 | error* |
| immediate (d) | 0.12 | 1.36 | 6.49 | 14.03 | 82.46 |
| delayed (d) | 0.13 | 0.49 | 2.97 | 6.00 | 28.88 |
Node v20.10.0 on Mac Air M1
Notes:
-
(f) indicates fixed dependencies.
-
(d) indicates dynamic dependencies.
-
The "manual (d)\5000 error*" is caused by a maximum call stack exceeded exception. Unavoidable in this test with closures that call upon each other.
Immediate and Delayed are not affected as they execute from the source and outward into the graph. The manual strategy only executes on the point of entry provided to it.
-
Immediate is somewhat slower (in this test) since this strategy executes all affected dependencies each time a single change has occurred.
- (In Progress) Move logic from signal-effect into signal-effect-cs
- Scoped execution handlers.
- Scoped create and drop. Track creation of primitives and drop them together.
- Allow internal modification of objects and arrays without set. Avoids the need to reconstruct the entire object/array.
- Automatic dependency discovery for deriveds and effects. Just provide the callback and the rest is figured out.
- Explicit removal of effects and deriveds (drop). Useful in Immediate/Delayed execution.
- Custom execution handlers.
- Support reading (of independent) signals in effect calculations.
- Support writing to signals from effect calculations
- Throw error when setting a readonly signal directly. Should exhibit the same behavior as when it is used as a property.
- ...
Signal systems are becoming the de-facto standard for reactivity in js/ts land. This C# library is based on my signal-effect implementation for js/ts. The C# variant is still in progress, visit later.