|
1 | | -# Signals |
2 | | -Reactive and safe signals. |
| 1 | +# @moq/signals |
3 | 2 |
|
4 | | -TODO More docs. |
| 3 | +Reactive signals with explicit tracking. |
| 4 | +No magic or footguns. |
5 | 5 |
|
6 | | -## Why? |
7 | | -Solid signals are nice, but there's too much magic. |
8 | | -It's very easy to shoot yourself in the foot by calling Accessors from outside of an effect. |
9 | | -The current execution context is difficult to track and there's many unintuitive edge cases. |
| 6 | +## Usage |
| 7 | +```sh |
| 8 | +bun add @moq/signals |
| 9 | +``` |
10 | 10 |
|
11 | | -React signals require explicit dependencies. |
12 | | -I don't want to list all of the signals that *might* be used in an effect. |
13 | | -I would much rather just run the function and record what signals were used, re-running it on change. |
| 11 | +### Signal |
| 12 | +A `Signal` holds a reactive value. |
| 13 | + |
| 14 | +```ts |
| 15 | +import { Signal } from "@moq/signals"; |
| 16 | + |
| 17 | +const count = new Signal(0); |
| 18 | + |
| 19 | +count.peek(); // 0 |
| 20 | +count.set(1); // notifies subscribers |
| 21 | +count.update(n => n + 1); // update via function |
| 22 | + |
| 23 | +const dispose = count.subscribe(n => console.log(n)); // subscribe to changes manually |
| 24 | +``` |
| 25 | + |
| 26 | +Updates are batched, coalescing multiple updates into a single microtask. |
| 27 | +The old and new values are then compared with deep equality ([dequal](https://github.com/lukeed/dequal)) to avoid unnecessary wakeups. |
| 28 | +It's possible to skip this check, but please benchmark it first... |
| 29 | + |
| 30 | +### Effect |
| 31 | +An `Effect` is a reactive scope. |
| 32 | +It re-runs whenever a tracked signal changes. |
| 33 | + |
| 34 | +```ts |
| 35 | +import { Signal, Effect } from "@moq/signals"; |
| 36 | + |
| 37 | +const name = new Signal("world"); |
| 38 | + |
| 39 | +const effect = new Effect((effect) => { |
| 40 | + const value = effect.get(name); // read AND track |
| 41 | + console.log(`Hello, ${value}!`); |
| 42 | +}); |
| 43 | + |
| 44 | +name.set("signals"); // effect reruns: "Hello, signals!" |
| 45 | + |
| 46 | +effect.close(); // cleanup |
| 47 | +``` |
| 48 | + |
| 49 | +The key difference from other libraries: **`effect.get(signal)` is what subscribes**. |
| 50 | +If you just want to read without tracking, use `signal.peek()` directly. |
| 51 | + |
| 52 | +### effect.cleanup |
| 53 | +Run a cleanup function when the effect reruns or closes. |
| 54 | + |
| 55 | +```ts |
| 56 | +const name = new Signal("world"); |
| 57 | + |
| 58 | +const effect = new Effect((effect) => { |
| 59 | + const value = effect.get(name); |
| 60 | + console.log(`Hello, ${value}!`); |
| 61 | + |
| 62 | + effect.cleanup(() => console.log(`Goodbye, ${value}!`)); |
| 63 | +}); |
| 64 | +``` |
| 65 | + |
| 66 | +### effect.run |
| 67 | +Create a nested effect that can be rerun independently. |
| 68 | +It will be closed when the parent effect reruns or closes. |
| 69 | + |
| 70 | +```ts |
| 71 | +const name = new Signal("world"); |
| 72 | +const age = new Signal(20); |
| 73 | + |
| 74 | +const effect = new Effect((effect) => { |
| 75 | + const n = effect.get(name); |
| 76 | + console.log(`Hello, ${n}!`); |
| 77 | + |
| 78 | + // NOTE: use the nested effect's argument, not the parent's |
| 79 | + effect.run((nested) => { |
| 80 | + const a = nested.get(age); |
| 81 | + console.log(`You are ${a} years old!`); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
| 85 | +age.set(21); // only the nested effect reruns: "You are 21 years old!" |
| 86 | +``` |
| 87 | + |
| 88 | +### effect.spawn |
| 89 | +Run async work that blocks the effect from re-running until it completes. |
| 90 | +The promise should monitor the `effect.cancel` Promise to detect when the effect wants to rerun. |
| 91 | + |
| 92 | +```ts |
| 93 | +const url = new Signal("/api/data"); |
| 94 | + |
| 95 | +const effect = new Effect((effect) => { |
| 96 | + const endpoint = effect.get(url); |
| 97 | + |
| 98 | + effect.spawn(async () => { |
| 99 | + const request = fetch(endpoint); |
| 100 | + const res = await Promise.race([request, effect.cancel]); |
| 101 | + if (!res) return; // effect cancelled, stop the request |
| 102 | + |
| 103 | + // ... |
| 104 | + }); |
| 105 | +}); |
| 106 | +``` |
| 107 | + |
| 108 | +### Helpers |
| 109 | + |
| 110 | +Effects also provide lifecycle helpers that auto-cleanup: |
| 111 | + |
| 112 | +- **`effect.set(signal, value, cleanup)`** - temporarily set the value of a signal for the duration of the effect |
| 113 | +- **`effect.timer(fn, ms)`** - `setTimeout` that cancels on cleanup |
| 114 | +- **`effect.interval(fn, ms)`** - `setInterval` that cancels on cleanup |
| 115 | +- **`effect.animate(fn)`** - `requestAnimationFrame` that cancels on cleanup |
| 116 | +- **`effect.event(target, type, fn)`** - `addEventListener` that removes on cleanup |
| 117 | +- **`effect.subscribe(signal, fn)`** - shorthand: run `fn` each time `signal` changes |
| 118 | +- **`effect.getAll(signals)`** - get the values of multiple signals, only if they are all truthy |
| 119 | + |
| 120 | +## Framework Integrations |
| 121 | + |
| 122 | +### Solid.js |
| 123 | + |
| 124 | +```ts |
| 125 | +import solid from "@moq/signals/solid"; |
| 126 | + |
| 127 | +const count = new Signal(0); |
| 128 | +const value = solid(count); // returns a Solid Accessor |
| 129 | +``` |
| 130 | + |
| 131 | +### React |
| 132 | + |
| 133 | +```ts |
| 134 | +import react from "@moq/signals/react"; |
| 135 | + |
| 136 | +function Component() { |
| 137 | + const value = react(count); // uses useSyncExternalStore under the hood |
| 138 | +} |
| 139 | +``` |
0 commit comments