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 onX handlers, native on:x listeners, 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 and getAbortSignal(). 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

tsx
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. Read count() where you need it, or derive: const double = () => count() * 2 (LUM2001).
  • Calling set with 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])), use store(), 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 effect to derive state. effect(() => b.set(a() * 2)) adds a frame of staleness and an extra flush. Use computed(() => 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() and structuredClone() throw on proxies: send snapshot(state).
  • Expecting store() to wrap class instances, Maps or Sets. Only plain objects and arrays become reactive; other values are returned as is.
  • 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.