ArcMoon 1.1.0

Start

Language

Reference

Nothing matches “”.

↑↓ move Enter open Esc close

Language

Reactivity

arcmoon/reactive: signal, computed and effect, lists that change, and sharing state.

The alien-signals logo

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 bundle entry, 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 effect

Don'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:

index.arcm
[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:

index.arcm
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]
Result

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 puts 0 in it. likes is the box.
  • 2. Read: likes(), with empty brackets, gives what is inside: 0. Because the [p] read it, ArcMoon remembers that this text depends on likes.
  • 3. Set: likes(1), with a value, puts 1 in 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 shows 1 likes, without any code to change it.

A signal can hold any value: a number, text, true / false, an array, an object. Try it:

index.arcm
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]
Result

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 computed a function. It reads price() and qty(), so it depends on both.
  • You read it like a signal: total(). You never set it.
  • When price or qty changes, total is 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:

index.arcm
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]
Result

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 on theme.
  • 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:

index.arcm
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]
Result
  • 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.

index.arcm
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]
Result

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:

store.js
import { signal } from "arcmoon/reactive";

export const total = signal(0);
Adder.arcm
runtime ${ import { total } from "./store.js"; }$
[button = onclick: runtime ${ () => total(total() + 1) }$]Add[end]
Show.arcm
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]