Concepts
Signals and reactivity
State lives in signals: read with count(), write with count.set(v), and only the code that read a signal re-runs.
#Concept
Lumiose's reactive core is a push-pull signal graph (the alien-signals algorithm). Reading a signal
inside a tracked scope (a JSX binding, a computed, an effect) subscribes that scope; writing
the signal marks it dirty and re-runs only what depends on it.
- Reads are synchronous.
count()always returns the current value, even right after a write. - Writes are batched. Writes inside an event handler are batched for the duration of the
handler: delegated
onXhandlers, nativeon:xlisteners, the direct listeners of non-bubbling events (onFocus,onScroll) and handlers passed in a spread. They render together when it returns, so the DOM is current right after the event (decision-81). Any other write schedules a microtask flush.flush()flushes immediately. - Flush order: computeds are pulled, then DOM bindings run (parents before children), then user effects. The effects queued at that point all run before the DOM bindings that their own writes trigger, so effects that measure the DOM and then write share one layout. Then those bindings run, then the effects that the writes scheduled. A later effect of the same pass reads the new signal values, but the DOM does not show them yet. A conditional or list around a queued effect that such a write changed still updates first, so an effect never runs inside content that an earlier effect's write removed.
- Owners. Every component, effect and root is an owner. Owners scope cleanups
(
onCleanup), context (<Ctx value>/useContext), error handling andgetAbortSignal(). Disposing an owner disposes everything created under it. - Equality. A write that is
Object.is-equal to the current value is not a change. Pass{ equals }to customize, or{ equals: false }to treat every write as a change. - On the server signals are plain cells, computeds run once, and effects never run.
#API
| API | Shape |
|---|---|
signal(value, { equals? }) |
s() reads, s.set(v) and s.update(fn) write, s.peek() reads untracked. |
computed(fn, { equals? }) |
c() reads. Lazy, cached and glitch-free; fn receives the previous value. |
effect(fn) |
Runs after the DOM updates and again when what it read changes. fn may return a cleanup. Returns a disposer. |
onMount(fn) |
Runs fn once, untracked, after the owner's DOM is mounted. May return a cleanup. |
onCleanup(fn) |
Runs fn when the current owner is disposed or re-runs. |
store(obj) |
Deep, mutable reactive proxy over a plain object or array (like Valtio's proxy or Solid's createMutable): mutate it directly. |
snapshot(value) |
A deep plain copy of a store, for structuredClone, postMessage and saving. |
untrack(fn) |
Runs fn without subscribing to what it reads. |
batch(fn) |
Groups writes; dependents run once at the end. |
flush() |
Runs pending bindings and effects now. |
getAbortSignal() |
An AbortSignal aborted when the owner is disposed, an effect re-runs, or an async run is superseded. On the server, the request's signal. |
createContext(default?), <Ctx value={v}>, useContext(ctx) |
Values passed down the owner tree; they survive await. The provider reads value once: provide a signal or store for changing state. |
resource(fetcher) |
Low-level on-demand loading with loading(), error(), refetch(). Prefer async components. |
In-flight state (isPending(), isBusy(), isSubmitting(), isNavigating()) is covered in
Async components and Data and actions.
#Example
import { computed, createContext, effect, signal, store, useContext } from "lumiose";
const Theme = createContext<"light" | "dark">("light");
export function Cart() {
const items = store([{ name: "Pen", price: 2, qty: 1 }]);
const discount = signal(0);
const total = computed(
() => items.reduce((sum, item) => sum + item.price * item.qty, 0) * (1 - discount()),
);
effect(() => {
document.title = `Cart: ${total().toFixed(2)}`;
});
return (
<Theme value="dark">
<section>
<button onClick={() => items.push({ name: "Ink", price: 5, qty: 1 })}>Add ink</button>
<button onClick={() => (items[0]!.qty += 1)}>One more pen</button>
<button onClick={() => discount.set(0.1)}>Apply 10%</button>
<p>Total: {total().toFixed(2)}</p>
<ThemeLabel />
</section>
</Theme>
);
}
function ThemeLabel() {
const theme = useContext(Theme);
return <small>Theme: {theme}</small>;
}#Common mistakes
- Destructuring or copying a signal's value in setup.
const n = count()freezes the value. Readcount()where you need it, or derive:const double = () => count() * 2(LUM2001). - Calling
setwith the same object after mutating it.list().push(x); list.set(list())is not a change (Object.is). Write a new value (list.update((l) => [...l, x])), usestore(), or create the signal with{ equals: false }. - Writing signals in the component body or in a
computed. That is a write during render (LUM2002). Computeds must be pure; write in handlers and effects. - Using
effectto derive state.effect(() => b.set(a() * 2))adds a frame of staleness and an extra flush. Usecomputed(() => a() * 2). - Expecting an effect to run synchronously. Effects run after the DOM updates. In tests, call
flush()after a write before asserting. - Measuring, in one effect, what another effect's write renders. Effects of the same pass all run before their writes reach the DOM, so the second effect measures the previous layout. Read the written signal in the effect that measures: it re-runs once the DOM shows the write.
- Module-scope signals in server code. On the server, a signal at module scope is shared by
every request. Keep request data in components or context (
LUM4008). - Posting a store to a worker.
postMessage()andstructuredClone()throw on proxies: sendsnapshot(state). - Expecting
store()to wrap class instances,Maps orSets. Only plain objects and arrays become reactive; other values are returned as is.
#Related diagnostics
- LUM2001: stale derived value.
- LUM2002: signal write during render.
- LUM2007: own signal read in an async body.
- LUM4008: module-scope signal written during SSR.
See also: reference: reactivity, JSX bindings, Sharing state.