Language
Reactivity
arcmoon/reactive: signal, computed and effect, lists that change, and sharing state.
ArcMoon's reactivity is powered by alien-signals, a small, fast signals library. GitHub · Docs · npm
What it is
arcmoon/reactive is a small, optional library for runtime code.
ArcMoon uses alien-signals for it, and gives you exactly three of its functions: signal,
computed and effect. Use them when something on the page should update by itself;
events and refs work without them.
runtime ${ import { signal, computed, effect } from "arcmoon/reactive"; }$- It needs no
bundleentry, and only works in runtime code, not in${ }$. - Only these three names exist. Anything else stops the build at the import:
✗ page.arcm:1:21 "effectScope" is not in arcmoon/reactive; it has signal, computed and effectDon't import alien-signals directly: it adds a second copy to the page, and live values don't follow its signals.
Why reactivity
The problem: keeping the page in sync
When data changes in the browser, every place that shows it must change too. Without reactivity, you update each place by hand:
[p]Items: [span = arcm-ref: "count"]0[end][end]
[p = arcm-ref: "left"]10 left[end]
[button = arcm-ref: "add"]Add[end]
runtime ${
let count = 0;
const countEl = ArcMoon.ref(ArcMoon.defineRef("count"));
const leftEl = ArcMoon.ref(ArcMoon.defineRef("left"));
const add = ArcMoon.ref(ArcMoon.defineRef("add"));
add.onclick = () => {
count++;
countEl.textContent = count;
leftEl.textContent = `${10 - count} left`;
add.disabled = count >= 10;
};
}$One number appears in three places, so the click handler must update all three. Add a fourth place and you must remember to update it there too; forget one and the page shows old data.
The fix: describe it once
With a signal, you write what each place shows, once. When the signal changes, ArcMoon updates every place that reads it. Click Add:
runtime ${
import { signal } from "arcmoon/reactive";
const count = signal(0);
}$
[p]Items: runtime ${ count() }$[end]
[p]runtime ${ 10 - count() }$ left[end]
[button = onclick: runtime ${ () => count(count() + 1) }$, disabled: runtime ${ count() >= 10 }$]Add[end]
The click handler only changes the data. The text, the count left and the disabled button follow by themselves.
How it works
- A live value that reads a signal remembers that it read it.
- When the signal is set, ArcMoon re-runs only those live values, and changes only their text or attribute.
- Nothing else on the page is touched: no re-render, no virtual DOM. The HTML stays the HTML ArcMoon built.
Why alien-signals
ArcMoon pages are plain HTML, so the reactivity must be small and only update the exact spots that change.
alien-signals does just that: a tiny, fast signals library, adding about 4 kB to a page that uses live values.
ArcMoon doesn't reinvent it; arcmoon/reactive passes it through, and only changes effect
to keep a returned function as cleanup. If you prefer React, Vue or another library, you can still import it as an npm package.
signal
A signal is a box that holds one value. You can look inside it, put a new value in it, and anything that looked inside is told when it changes. Here is a like button:
runtime ${
import { signal } from "arcmoon/reactive";
const likes = signal(0); // 1. a box that holds 0
}$
[p]runtime ${ likes() }$ likes[end] # 2. read the box: shows 0
[button = onclick: runtime ${ () => likes(likes() + 1) }$]Like[end] # 3. put a new value in it- 1. Create:
signal(0)makes the box and puts0in it.likesis the box. - 2. Read:
likes(), with empty brackets, gives what is inside:0. Because the[p]read it, ArcMoon remembers that this text depends onlikes. - 3. Set:
likes(1), with a value, puts1in the box.likes(likes() + 1)reads the old value and puts in one more. - 4. Update: the box tells everything that read it. The
[p]now shows1 likes, without any code to change it.
A signal can hold any value: a number, text, true / false, an array, an object. Try it:
runtime ${
import { signal } from "arcmoon/reactive";
const likes = signal(0);
const liked = signal(false);
}$
[p]runtime ${ likes() }$ likes[end]
[button = onclick: runtime ${ () => { likes(likes() + (liked() ? -1 : 1)); liked(!liked()); } }$]
runtime ${ liked() ? "Unlike" : "Like" }$
[end]
Two signals, likes and liked: the click changes both, and the count and the button text follow.
computed
Often a value isn't data of its own, but something worked out from other data. Say a shop shows a total that depends on the price and the quantity. You could keep the total in its own signal:
runtime ${
import { signal } from "arcmoon/reactive";
const price = signal(4.5);
const qty = signal(1);
const total = signal(4.5); // ✗ a copy you must keep right by hand
}$
[button = onclick: runtime ${ () => { qty(qty() + 1); total(price() * qty()); } }$]+1[end]Now every place that changes price or qty must also remember to update total. Miss one, and the total is wrong. computed fixes that: you write the formula once, and it always gives the right answer:
runtime ${
import { signal, computed } from "arcmoon/reactive";
const price = signal(4.5);
const qty = signal(1);
const total = computed(() => price() * qty()); // ✓ worked out from the two signals
}$
[p]Total: runtime ${ total() }$[end]
[button = onclick: runtime ${ () => qty(qty() + 1) }$]+1[end] # only change the data- You give
computeda function. It readsprice()andqty(), so it depends on both. - You read it like a signal:
total(). You never set it. - When
priceorqtychanges,totalis worked out again, and everything showing it updates. - It remembers its result, so reading it many times costs nothing until an input changes.
It works for lists too. Type to search the names:
runtime ${
import { signal, computed } from "arcmoon/reactive";
const names = ["Adam", "Elmi", "Eid", "Ismail"];
const search = signal("");
const found = computed(() => names.filter((n) => n.toLowerCase().includes(search().toLowerCase())));
}$
[input = placeholder: "Search", oninput: runtime ${ (e) => search(e.target.value) }$ !]
[p]runtime ${ found().length }$ found: runtime ${ found().join(", ") }$[end]
Typing only sets search. found works out the matching names from it, and the count and the list follow.
effect
Live values show data on the page. But sometimes a change must do something else: save to storage, change the page title, start a timer. That is what effect is for. Here a theme choice is saved, so it's still there after a reload:
runtime ${
import { signal, effect } from "arcmoon/reactive";
const theme = signal(localStorage.getItem("theme") ?? "light"); // 1. start from the saved choice
effect(() => {
localStorage.setItem("theme", theme()); // 2. runs now, and 3. again on every change
});
}$
[button = onclick: runtime ${ () => theme(theme() === "light" ? "dark" : "light") }$]
Theme: runtime ${ theme() }$
[end]- 1. The signal starts from the saved value, or
"light"the first time. - 2. Runs now: the effect runs once right away. While it runs, it reads
theme(), so it now depends ontheme. - 3. Runs again: each click sets
theme, so the effect runs again and saves the new value. You never call it yourself.
Cleaning up. Some work must be undone before it runs again, like a timer: start a new one without stopping the old, and you have two. Return a function from the effect, and it runs before the next run (and when the effect stops). Pause the timer:
runtime ${
import { signal, effect } from "arcmoon/reactive";
const running = signal(true);
const seconds = signal(0);
effect(() => {
if (!running()) return; // paused: no timer
const id = setInterval(() => seconds(seconds() + 1), 1000);
return () => clearInterval(id); // stop it before the next run
});
}$
[p]runtime ${ seconds() }$ seconds[end]
[button = onclick: runtime ${ () => running(!running()) }$]runtime ${ running() ? "Pause" : "Resume" }$[end]
- The effect reads
running(), so it runs again on each Pause / Resume. - Before it does, the returned function stops the old timer. Paused, it returns early and starts nothing.
seconds()inside the timer's callback is read later, not while the effect runs, so it doesn't make the effect run again.effect()gives back a function that stops it for good:const stop = effect(…); stop();.
Use an effect only for work outside the page's live values. To show a value, use a live value or computed.
Which one to use
- Data that changes (typed text, a count, a list):
signal. - A value worked out from other signals:
computed. - Show a value or set an attribute: a live value, like
runtime ${ count() }$. No effect needed. - Do something outside the live values (timer, title, storage, a library):
effect. - Only react to a click, with nothing to keep in sync: an event, no signal at all.
Lists that change
Keep the list in a signal, set a new array to change it, and draw it with a live [for-each]:
its list is runtime ${ }$, so the rows follow the signal (ArcMoon 1.1). More in Live loops.
runtime ${
import { signal } from "arcmoon/reactive";
const items = signal(["Item 1"]);
}$
[ul]
[for-each = runtime ${ items() }$, as: "item"]
[li]runtime ${ item }$[end]
[end]
[end]
[button = onclick: runtime ${ () => items([...items(), "Item " + (items().length + 1)]) }$]Add[end]
[button = onclick: runtime ${ () => items(items().slice(0, -1)) }$]Remove last[end]
Don't change an array or object in place: items().push(x) changes the array, but no signal is set, so nothing updates. Set a new one: items([...items(), x]).
Sharing between components
Each file has its own runtime variables. To share a signal, put it in a .js file and import it where you need it. A page imports it once, so every component gets the same signal:
import { signal } from "arcmoon/reactive";
export const total = signal(0);runtime ${ import { total } from "./store.js"; }$
[button = onclick: runtime ${ () => total(total() + 1) }$]Add[end]runtime ${ import { total } from "./store.js"; }$
[p]Total: runtime ${ total() }$[end]Clicking Add in one component updates the total in the other.
The first value is in the HTML
When a live value is exactly name() and the signal starts from a literal or an exported value, ArcMoon writes that first value into the HTML, so it shows before the script runs:
${ export const start = 5; }$
runtime ${
import { signal } from "arcmoon/reactive";
const a = signal(0); // written as 0
const b = signal(start); // written as 5
}$
[p]runtime ${ a() }$ runtime ${ b() }$ runtime ${ a() + b() }$[end]Anything else, like a() + b() or a computed value, is empty until the script runs.
Mistakes
Forgetting the ()
A signal is a function. Without () the page shows the function, not its value:
# ✗ shows "function () { [native code] }"
[p]runtime ${ count }$[end]
# ✓ right
[p]runtime ${ count() }$[end]Changing an array in place
# ✗ the array changes, but nothing updates
[button = onclick: runtime ${ () => items().push("Tea") }$]Add[end]
# ✓ set a new array
[button = onclick: runtime ${ () => items([...items(), "Tea"]) }$]Add[end]Using a plain variable
# ✗ count changes, but the page keeps showing 0
runtime ${ let count = 0; }$
[button = onclick: runtime ${ () => count++ }$]runtime ${ count }$[end]
# ✓ use a signal
runtime ${
import { signal } from "arcmoon/reactive";
const count = signal(0);
}$
[button = onclick: runtime ${ () => count(count() + 1) }$]runtime ${ count() }$[end]