Reference
lumiose: reactivity
Signatures of the reactive core: signals, computeds, effects, stores, context, ids and scoped state.
All of these are exported by lumiose in the browser and on the server. On the server (the node
entry) signals are plain cells, computeds evaluate once per request, and effect/onMount never
run. Concepts: Signals and reactivity.
#signal
function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
interface Signal<T> {
(): T; // read (tracked)
set(value: T): void;
update(fn: (value: T) => T): void;
peek(): T; // read without tracking
}
interface SignalOptions<T> {
/** When a write counts as a change. Default Object.is; false: every write is a change. */
equals?: false | ((a: T, b: T) => boolean);
}#computed
function computed<T>(fn: (previous: T | undefined) => T, options?: SignalOptions<T>): Computed<T>;
type Computed<T> = () => T;Lazy (runs on first read), cached, glitch-free. fn must be pure: no signal writes (LUM2002).
An unobserved computed unlinks itself from its sources.
#effect
function effect(fn: EffectFn): () => void;
type EffectFn = () => void | (() => void);Runs fn after the DOM updates, then again whenever a signal it read changes. A returned function
is a cleanup, run before the next run and on disposal. Returns a disposer. Owned by the current
owner, so it is disposed with the component.
#onMount
function onMount(fn: EffectFn): void;Runs fn once, untracked, after the current owner's DOM is mounted. It may return a cleanup.
#onCleanup
function onCleanup(fn: () => void): void;Registers fn on the current owner: it runs when the owner is disposed (or, for an effect, before
its next run).
#getAbortSignal
function getAbortSignal(): AbortSignal;An AbortSignal for the current owner. Aborted when the owner is disposed, when an effect re-runs,
or when an async component run is superseded. On the server it is the request's signal. Throws
outside an owner (with a message in development). Svelte has the same function.
#untrack
function untrack<T>(fn: () => T): T;Runs fn without subscribing the current scope to the signals it reads.
#batch
function batch<T>(fn: () => T): T;Defers dependents until fn returns, so they run once. Delegated event handlers are batched
automatically.
#flush
function flush(): void;Runs pending computeds, DOM bindings and effects now, instead of in the next microtask.
#store
function store<T extends object>(value: T): T;Wraps a plain object or array in a deep, mutable reactive proxy, like Valtio's proxy, Vue's
reactive or Solid's createMutable: you write to it directly (state.count++,
state.items.push(item)), with no setter. Reads track per property, writes notify only on change,
array methods work. Class instances, Maps, Sets and frozen objects are returned as is.
#snapshot
function snapshot<T>(value: T): T;A deep plain copy of a store, of a value inside one (snapshot(state.items)), or of plain data
that holds stores. Plain objects and arrays are copied; other values (Dates, Maps, class
instances) are kept as they are, and shared or circular references stay shared. Use it where a
proxy does not work: structuredClone() and postMessage() (to a worker or an AudioWorklet)
throw on proxies, and a copy does not change when the store does (saving, undo history). Reads
are not tracked: in an effect, read the properties it should react to. On the server it copies the
plain object.
const song = store({ title: "Demo", rows: [] as number[][] });
worker.postMessage({ type: "song", song: snapshot(song) });#reconcile
function reconcile<T extends object>(target: T, data: T, options?: ReconcileOptions): void;
interface ReconcileOptions {
key?: string; // identifies array items, default "id"
}Patches fresh data (a fetch result, a server payload) into a store, or into an object or array
inside one (reconcile(state.books, fresh)), in place, with Solid's reconcile semantics:
- Array items are matched by reference, or by
keywhen the new item has it. Items without the key are merged into the item at the same index when that item has no key either; unmatched items are inserted as new values, and leftovers are removed. Keys should be unique: new items that share a key end up as the same store item. - Matched objects are merged deeply; an object whose
keydiffers from the old one is replaced. - Keys missing from
dataare deleted. Primitives and other values (Dates,Maps, class instances) are replaced.
Unchanged items keep their identity, so keyed list rows, their DOM and their local state survive,
and only properties whose value changed notify, all in one batch. Reads are not tracked, so it is
safe in an effect. target and data must be the same kind (both arrays or both plain objects),
otherwise it throws a TypeError in development. Pass the store, not plain data: a plain object is patched
without notifying anything (that is what happens on the server, where stores are plain objects).
const books = store<Book[]>([]);
reconcile(books, (await (await fetch("/api/books")).json()) as Book[]);
const page = store({ rows: [{ slug: "intro", views: 1 }] });
reconcile(page.rows, [{ slug: "intro", views: 2 }], { key: "slug" });#createContext, useContext
function createContext<T>(defaultValue: T): Context<T>;
function createContext<T>(): Context<T | undefined>;
function useContext<T>(context: Context<T>): T;
interface Context<T> {
(props: { value: T; children?: JSX.Element }): JSX.Element; // the provider component
readonly id: symbol;
readonly defaultValue: T;
}A context is also its provider component, as in React 19 and Solid 2: <Theme value="dark">…</Theme>
provides value to everything rendered inside it, including content rendered later (conditionals,
lists). useContext returns the value of the nearest provider above, or the default. A nested
provider of the same context shadows the outer one for its subtree. Context survives await in
async components. Islands start a new root (LUM4010).
The provider reads value once, when it renders, like Solid's provider and like every component
prop read in a component body: components run once. To share state that changes, provide a signal,
a store or an object with getters, and read it where it is used.
const Theme = createContext("light");
const Count = createContext(signal(0));
<Theme value="dark">
<Count value={signal(5)}>
<Toolbar /> {/* useContext(Theme) is "dark"; useContext(Count)() is reactive */}
</Count>
</Theme>;#useId
function useId(): string;Returns an id that is unique in the page, for id/for, aria-labelledby, aria-describedby,
aria-controls and the like. Call it in a component's setup, once per id you need; each call returns
the instance's next id. The id is a valid HTML id and CSS identifier (l0-0-1-0, ls3, lc7), so
it also works in selectors and as a view-transition-name. Do not parse it: the format may change.
- Server and hydration. Inside an island (and an SPA, which is one), the server builds the id
from the island's id and the component instance's position in it, and hydration computes the same
id, so the server's
for/idpairs stay as they are. Outside islands, server ids follow the render tree, so the same page renders the same ids whatever order its async content resolves in. - Browser only. Content rendered in the browser after hydration, or by
render(), gets ids from a counter. - Several apps on one page. Give each a prefix:
render(code, container, { idPrefix: "cart-" }), orrenderToStream(root, { idPrefix })on the server, which the page's islands reuse when they hydrate. Ids then start with the prefix. - Async components. Each run of an async component runs its body again, so a re-run after a signal it read changed gets new ids, as its other locals are new too.
import { useId } from "lumiose";
export function EmailField(props: { label: string; hint: string }) {
const id = useId();
const hintId = useId();
return (
<p>
<label for={id}>{props.label}</label>
<input id={id} type="email" aria-describedby={hintId} />
<small id={hintId}>{props.hint}</small>
</p>
);
}
export function SignUp() {
const title = useId();
return (
<form aria-labelledby={title}>
<h2 id={title}>Sign up</h2>
<EmailField label="Email" hint="We never share it." />
<EmailField label="Backup email" hint="Optional." />
</form>
);
}#defineState
function defineState<T>(factory: () => T): () => T;Scoped state: returns an accessor whose factory runs on the first call, once per scope, and
returns the same instance after that.
| Scope | One instance per |
|---|---|
| Browser | page: shared by every island and the SPA tree |
| Server | request: middleware, endpoints, actions and the render share it; never across requests |
| Tests (Vitest) | test: @lumiose/vite resets scoped state before each test |
The factory runs outside the component tree: it sees no context and its reads are not tracked; in
the browser its effects live as long as the page. On the server it may read request APIs
(cookies(), locals(), …), which do not exist in the browser: guard them with
isServer. Calling an accessor on the server outside a request or render (at module
scope) throws.
Sent to the browser. When islands or SPA routes import the state, a page that created it sends what the factory returned that the browser cannot rebuild: the returned signal or store itself, or the top-level signals and stores of the returned object. Computeds and functions are not sent; the factory runs again in the browser and the server values replace its signals and stores before any island reads them, so hydration matches. Values go once per page, in the chunk (shell or streamed boundary) where the state was first used, and must be plain data like island props (LUM4011). States that only server code imports are never sent. Everything a sent state holds is public: never put secrets in it.
Define states at module scope with export const x = defineState(…) (or export the binding):
@lumiose/vite names each one after its module and export, the way it names islands. States
created elsewhere (inside a function) are never sent.
import { computed, cookies, defineState, isServer, signal } from "lumiose";
export const prefs = defineState(() => {
const theme = signal(isServer ? (cookies().get("theme") ?? "light") : "light");
return { theme, dark: computed(() => theme() === "dark") };
});
// prefs().theme(), prefs().dark(), prefs().theme.set("dark")#isServer
const isServer: boolean;true in the server entry of lumiose (the node condition), false in the browser entry
(Solid's name). Use it to guard code that runs on both sides, such as a defineState factory that
reads request APIs: signal(isServer ? locals().user : null). The guard stays explicit: request
APIs called in the browser still throw LUM4003.
It is a constant, so production client builds fold it: if (isServer) { … } and
isServer ? a : b lose their server branch, and server-only strings in it never reach the
bundle. The server branch must still type-check and import only what client code may import;
server-only modules stay out of client graphs (LUM4003).
#isPending
function isPending(): boolean;Inside an async component (its body or its UI): whether a re-run is in flight. The first load is
not a re-run (<Suspense> covers it). Reading it in the body does not re-run the component.
Always false on the server.
#isBusy
function isBusy(): boolean;App-wide: whether anything is in flight: an async component run, a <Suspense> showing its
fallback, a form submission (submit()), an SPA navigation or a request of the async cache
(fetchJSON, query), background refetches included. Reactive. Always false on the
server.
#resource
function resource<T>(fetcher: (signal: AbortSignal) => T | PromiseLike<T>): Resource<T>;
interface Resource<T> {
(): T | undefined; // latest value, tracked
loading(): boolean;
error(): unknown;
refetch(): void; // aborts a load in flight and loads again
}Low-level on-demand loading. fetcher runs now and whenever a signal it read synchronously
changes; its AbortSignal aborts when superseded or disposed. The first load counts as pending
for the nearest <Suspense>. Errors are not thrown: read error(). Browser only: on the server it
stays loading. Prefer async components.
#optimistic
function optimistic<T>(source: () => T): Optimistic<T>;
interface Optimistic<T> {
(): T; // source() with the pending overlays applied in order, tracked
set(value: T, promise: PromiseLike<unknown>): void;
update(fn: (value: T) => T, promise: PromiseLike<unknown>): void;
}A value that follows source (a getter such as () => props.likes, or a signal), plus overlays
held while their promises are pending. Each set/update applies at once and drops when its own
promise settles, either way: on success the (refreshed) source shows, on failure the value rolls
back. Concurrent overlays apply in call order over the latest source value, so an update is
re-applied when the source changes. A rejection is not re-thrown; await the promise itself to
handle it. Writes stop when the owner is disposed. On the server it reads source() and set/update
do nothing. See optimistic updates in Data and actions.
#Example
import { batch, effect, getAbortSignal, onCleanup, resource, signal, untrack } from "lumiose";
export function Clock() {
const now = signal(new Date());
const timer = setInterval(() => now.set(new Date()), 1000);
onCleanup(() => clearInterval(timer));
const first = signal("Ada");
const last = signal("Lovelace");
const rename = () =>
batch(() => {
first.set("Grace");
last.set("Hopper");
});
effect(() => {
console.log(`${first()} ${last()} at`, untrack(now).toISOString());
});
const user = resource((signal) =>
fetch(`/api/users/${first()}`, { signal }).then((res) => res.json() as Promise<{ id: string }>),
);
return (
<p onClick={rename}>
{now().toLocaleTimeString()} · {user.loading() ? "…" : user()?.id}
{String(getAbortSignal().aborted)}
</p>
);
}