State and reactivity

REALM's reactivity has three pieces: useState for state, getters for derived values, and observe for side effects. There are no dependency lists. REALM records what each binding, getter and observer reads, and re-runs only those when the values change.

useState

useState(object) returns a reactive proxy of the object. Reading a property inside a slot, binding, getter or observer subscribes to it. Writing it notifies them.

const todo = useState({
  items: [],
  draft: "",
  filter: "all",
});

todo.draft = "Buy milk"; // updates every slot and binding that reads todo.draft
todo.items.push({ id: 1, text: "Buy milk", done: false });
todo.items[0].done = true; // nested objects are reactive too
delete todo.filter; // added and deleted keys are tracked

Plain variables are not reactive. let count = 0 followed by count++ changes a variable that nothing observes, so the page does not update. Put anything the markup shows into useState.

Getters are derived values

A getter on a state object is a cached derived value. It computes on first read, then only again after something it read has changed.

const cart = useState({
  items: [],
  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  },
  get totalLabel() {
    return `$${this.total.toFixed(2)}`;
  },
  get isEmpty() {
    return this.items.length === 0;
  },
});

Markup only holds references, so getters are where comparisons and formatting go:

<p _hidden="#cart.isEmpty">Total: <slot name="#cart.totalLabel"></slot></p>

Getters can read props, other state objects and stores. When two getters share an input, observers still run once and never see a stale value. A setter on a state object runs with the proxy as this, so its writes are reactive. A getter that throws reports the error and recovers on the next change.

Updates are batched

Changes are collected and applied once per microtask. Five writes in a row cause one update of each affected binding:

function reset() {
  form.name = "";
  form.email = "";
  form.message = "";
  form.sent = false;
}

observe

observe(fn) runs fn now, and again whenever anything it read changes. It returns a function that stops it. In a component script, observers stop automatically when the element is removed.

If fn returns a function, that function runs before the next run and when the observer stops. Use it to undo what the run started:

Previewindex.htmlstop-watch.html
s Reset
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>State and reactivity demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>

  <import-element from="./stop-watch.html"></import-element>

  <stop-watch></stop-watch>
</body>
</html>
<custom-element name="stop-watch">
  <script>
    const props = defineProps({ interval: prop.number().min(10).default(100) });

    const watch = useState({
      elapsed: 0,
      running: false,
      get seconds() {
        return (this.elapsed * props.interval / 1000).toFixed(1);
      },
      get action() {
        return this.running ? "Stop" : "Start";
      },
    });

    function toggle() {
      watch.running = !watch.running;
    }

    function reset() {
      watch.elapsed = 0;
    }

    observe(() => {
      if (!watch.running) return;
      const id = setInterval(() => watch.elapsed++, props.interval);
      return () => clearInterval(id);
    });
  </script>

  <style>
    :host {
      display: inline-flex;
      gap: calc(var(--spacing) * 2);
      align-items: center;
      font-variant-numeric: tabular-nums;
    }
    output {
      min-width: calc(var(--spacing) * 16);
      font-size: var(--text-2xl);
      font-weight: var(--font-weight-semibold);
    }
  </style>

  <output><slot name="#watch.seconds"></slot>s</output>
  <ui-button onclick="toggle()"><slot name="#watch.action"></slot></ui-button>
  <ui-button variant="outline" onclick="reset()">Reset</ui-button>
</custom-element>

The observer reads watch.running and props.interval. When either changes, the previous interval is cleared by the returned function and a new run decides whether to start another. Removing the element stops it.

Things to know:

Why not an effect with a dependency array

observe has no list to keep in sync with the code, and it does not run after every render, because there are no renders. It runs once, then only when something it read has changed.

Reading without subscribing

untrack(fn) runs fn without subscribing the current observer to what it reads. toRaw(proxy) returns the original object behind a reactive proxy, which is useful before handing data to structuredClone or JSON.stringify in hot paths. Both are exported from @realmorg/realm:

import { toRaw, untrack } from "@realmorg/realm";

tick

Updates are applied in a microtask, so right after a state change the DOM still shows the old value. await tick() waits until pending updates have been applied:

async function edit(item) {
  item.editing = true;
  await tick();
  refs.editor.focus();
}

flush(), exported from the package, applies pending updates synchronously. You rarely need it outside tests.

Using state outside components

useState and observe are also exported from @realmorg/realm for use in plain modules. Observers created there are not tied to any element, so stop them yourself with the function observe returns. For state shared between components, use a store.

Edit this page on GitHub · View as Markdown