Start here

Coming from React

Lumiose JSX reads like React, but components run once and never re-render: what changes, what goes away, and the new mistakes to avoid.

This page maps React habits to Lumiose. The linked pages have the full rules.

#Mental model

In React, a state change re-runs the component function. Hooks keep their state between runs by call order, and effects list their dependencies so React knows when to re-run them.

In Lumiose, the component body runs once per instance: it creates signals, effects and handlers, then returns JSX. JSX expressions that read signals compile to bindings that update their part of the DOM in place. When a signal changes, only the bindings, computeds and effects that read it run again. Local variables are ordinary variables, and a closure reads the current value because it calls count() when it runs (Components, Signals).

The exception is an async component: when a prop or signal read in its body changes, the body runs again, and signals created in the body start fresh (Async components).

#From React to Lumiose

React Lumiose
const [count, setCount] = useState(0) const count = signal(0): read count(), write count.set(1) or count.update((n) => n + 1).
useReducer(reducer, init) A store() plus the functions that change it (a store with actions).
useMemo(() => a * b, [a, b]) computed(() => a() * b()), or a plain function () => a() * b() for cheap derivations.
useCallback(fn, deps), React.memo(Component) Not needed: functions are created once and components don't re-render.
useEffect(fn, [deps]) effect(fn): it tracks what it reads. Return a cleanup, as in React.
useEffect(fn, []) onMount(fn): runs once, untracked, after mount. It may return a cleanup.
useLayoutEffect(fn, deps) effect(fn) or onMount(fn): there is one kind of effect, and it runs after the DOM writes of the same flush.
Cleanup returned from useEffect, AbortController onCleanup(fn), and getAbortSignal(), aborted when the owner is disposed or the effect re-runs.
useRef<HTMLInputElement>(null) const input = ref<HTMLInputElement>(), then <input ref={input} /> and input()?.focus().
useRef(value) for a mutable value A let variable in the component body: the body runs once.
<Ctx.Provider value={v}>, useContext(Ctx) <Ctx value={v}>, useContext(Ctx). The provider reads value once: provide a signal or a store for state that changes.
<ViewTransition>, startTransition around it <ViewTransition name? enter? exit?>, and the update in viewTransition(() => …) (reference).
useId(), identifierPrefix useId(), and idPrefix on render() and renderToStream() (useId).
useSyncExternalStore(subscribe, get) A signal set from the store's subscribe (an external store).
Fetching in useEffect, TanStack Query An async component that awaits fetchJSON() or query() (Async components), or @lumiose/query.
useTransition, isPending isPending() inside an async component, and isBusy() for anything in flight.
useOptimistic(state, update) optimistic(source) (Data and actions).
<Suspense>, error boundary classes <Suspense fallback={…}> and <ErrorBoundary fallback={(error, reset) => …}>.
createPortal(children, node) <Portal mount={node}>.
{items.map((item) => <Row key={item.id} />)} The same, with items(). It compiles to a keyed list; key is optional (JSX).
className, htmlFor, style={{ fontWeight: 600 }} class, for, style={{ "font-weight": 600 }} (kebab-case keys).
dangerouslySetInnerHTML={{ __html: html }} innerHTML={html}.
onChange on a text input (fires on every keystroke) onInput, or bind:value={signal}. Handlers get native events, so onChange fires when the value is committed.

#What goes away

  • The rules of hooks. signal, computed, effect and onCleanup are ordinary functions: call them in conditions, loops and helper functions. Nothing depends on call order.
  • Dependency arrays. Effects and computeds subscribe to the signals they read, and collect them again on every run, so a dependency cannot be missing, and a branch that stops reading a signal stops depending on it.
  • Stale closures. A handler or a timer callback calls count() when it runs, so it gets the current value. There is no closure from an older render to capture.
  • Memoization. useMemo, useCallback and React.memo exist to limit re-renders, and there are none. Use computed only to cache an expensive derivation.
  • Context re-renders. A context change does not re-render its consumers: provide a signal or a store, and only the code that reads it updates.

#Mistakes that replace them

React habit What happens in Lumiose Instead
const doubled = count() * 2 in the body Computed once, never updates. const doubled = () => count() * 2, or computed (LUM2001).
if (!user()) return <Login /> Checked once, at setup. {user() ? <Profile /> : <Login />} in the JSX (LUM2003).
count.set(…) in the body A write during render. Write in handlers and effects (LUM2002).
effect(() => b.set(a() * 2)) to sync state A frame of staleness and an extra flush. const b = computed(() => a() * 2).
Reading a signal in an effect only to use its current value The effect re-runs whenever that signal changes. untrack(() => s()) or s.peek() reads without subscribing.
Asserting right after a write in a test Effects run after the DOM updates, not synchronously. Call flush() before asserting.
{Card({ title })} Skips the component's owner and props handling. <Card title={title} /> (LUM2004).
{children} inserted twice The second insert moves or re-creates the nodes. Take a render prop such as renderItem: () => JSX.Element (LUM2005).
A signal created and read in an async component's body The read re-runs the body, which re-creates the signal. Read it in JSX, handlers or effects (LUM2007).
A module-scope signal written on the server Shared by every request. Component state, context or defineState (LUM4008).

Destructuring props is fine: ({ title }) compiles to reads of props.title, which stay reactive. Copying a signal's value into a variable is what freezes it.

#Example

A clock that pauses, in React:

tsx
function Clock({ label }: { label: string }) {
  const [now, setNow] = useState(Date.now());
  const [paused, setPaused] = useState(false);

  useEffect(() => {
    if (paused) return;
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, [paused]);

  useEffect(() => {
    document.title = `${label}: ${new Date(now).toLocaleTimeString()}`;
  }, [label, now]);

  const toggle = useCallback(() => setPaused((p) => !p), []);
  return <button onClick={toggle}>{paused ? "Resume" : "Pause"}</button>;
}

The same clock in Lumiose. The effects list no dependencies, and the handler needs no useCallback:

tsx
import { effect, signal } from "lumiose";

export function Clock({ label }: { label: string }) {
  const now = signal(Date.now());
  const paused = signal(false);

  // Reads paused(): re-runs, after clearing the interval, when it flips.
  effect(() => {
    if (paused()) return;
    const id = setInterval(() => now.set(Date.now()), 1000);
    return () => clearInterval(id);
  });

  // Reads label (props.label) and now(): re-runs when either changes.
  effect(() => {
    document.title = `${label}: ${new Date(now()).toLocaleTimeString()}`;
  });

  return <button onClick={() => paused.update((p) => !p)}>{paused() ? "Resume" : "Pause"}</button>;
}
  • Components: setup once, props, children and refs.
  • Signals and reactivity: signal, computed, effect, owners and scheduling.
  • JSX: bindings, compiled .map() and ternaries, and the React prop names that differ.
  • Sharing state: context, defineState, and what replaces Jotai, Zustand and Valtio.
  • Async components: data loading, re-runs, <Suspense> and isPending().