Skip to content

Commit fe34ff2

Browse files
authored
Add a simple readme for signals. (#974)
1 parent 94082ea commit fe34ff2

2 files changed

Lines changed: 146 additions & 11 deletions

File tree

js/signals/README.md

Lines changed: 136 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,139 @@
1-
# Signals
2-
Reactive and safe signals.
1+
# @moq/signals
32

4-
TODO More docs.
3+
Reactive signals with explicit tracking.
4+
No magic or footguns.
55

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+
```
1010

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+
```

js/signals/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,10 @@ export class Effect {
278278

279279
if (this.#fn) {
280280
this.#fn(this);
281+
282+
if (DEV && this.#unwatch.length === 0) {
283+
console.warn("Effect did not subscribe to any signals; it will never rerun.", this.#stack);
284+
}
281285
}
282286
}
283287

@@ -414,7 +418,7 @@ export class Effect {
414418
}
415419

416420
// Create a nested effect that can be rerun independently.
417-
effect(fn: (effect: Effect) => void) {
421+
run(fn: (effect: Effect) => void) {
418422
if (this.#dispose === undefined) {
419423
if (DEV) {
420424
console.warn("Effect.nested called when closed, ignoring");
@@ -426,6 +430,11 @@ export class Effect {
426430
this.#dispose.push(() => effect.close());
427431
}
428432

433+
// Backwards compatibility with the old name.
434+
effect(fn: (effect: Effect) => void) {
435+
return this.run(fn);
436+
}
437+
429438
// Get the values of multiple signals, returning undefined if any are falsy.
430439
getAll<S extends readonly Getter<unknown>[]>(
431440
signals: [...S],

0 commit comments

Comments
 (0)