Reactivity

Reactivity

Signals hold values, memos derive from them, effects react to them. Veloz tracks who reads what and re-runs only what depends on a change.

Signals

A signal is a getter and a setter. Reading it inside a computation (an effect, a memo, a function in JSX) subscribes that computation.

ts
import { createSignal } from '@velozjs/core';

const [count, setCount] = createSignal(0);

count();               // 0
setCount(5);           // set a value
setCount((c) => c + 1); // or update from the previous one

By default, setting the same value (by Object.is) does nothing. Pass { equals: false } to notify every time, or your own comparison:

ts
const [list, setList] = createSignal<string[]>([], { equals: false });

Memos

A memo is a cached derived value. It recomputes only when one of its sources changes, and only notifies dependents when its result is different.

ts
const [price, setPrice] = createSignal(24);
const [qty, setQty] = createSignal(2);

const subtotal = createMemo(() => price() * qty());
const shipping = createMemo(() => (subtotal() >= 100 ? 0 : 9));

Here, changing qty from 2 to 3 recomputes subtotal, which recomputes shipping; but if shipping stays at 9, nothing that reads only shipping runs again. The playground on the home page shows exactly this.

Effects

An effect runs right away and again whenever a signal it read changes. Use it for side effects: logging, syncing with localStorage, talking to a non-Veloz library.

ts
createEffect(() => {
  localStorage.setItem('qty', String(qty()));
});

The previous return value is passed back as an argument, which helps with diffs:

ts
createEffect((previous?: number) => {
  const now = count();
  if (previous !== undefined) console.log(`${previous} → ${now}`);
  return now;
});

Cleanup

onCleanup registers a function that runs before the effect runs again and when its owner is disposed.

ts
createEffect(() => {
  const id = setInterval(tick, delay());
  onCleanup(() => clearInterval(id));
});

How updates are scheduled

  • Batched: writes inside batch() (and writes made while an effect runs) are flushed once, at the end.
  • Glitch-free: when a signal changes, dependents are marked first and run afterwards in order, so an effect never sees a memo that is out of date, and runs once even if several of its sources changed.
  • Errors don't stall the queue: if one effect throws, the others still run; the error is rethrown after the flush (or sent to the nearest catchError / ErrorBoundary).
  • Loops are caught: effects that keep re-triggering each other throw an error instead of freezing the page.
ts
batch(() => {
  setFirst('Grace');
  setLast('Hopper');
}); // effects that read both run once

Reading without subscribing

ts
import { untrack, on } from '@velozjs/core';

createEffect(() => {
  // re-runs when `query` changes, not when `page` does
  search(query(), untrack(page));
});

// Explicit dependencies; the callback runs untracked.
createEffect(on(query, (value, previous) => console.log(previous, '→', value), { defer: true }));
createEffect(on([first, last], ([f, l]) => console.log(f, l)));

Ownership

Every computation belongs to an owner: the component, effect or root that created it. Disposing an owner disposes everything it owns, so effects inside a component stop when the component goes away (a Show branch that turns false, a removed For row...).

ts
import { createRoot } from '@velozjs/core';

const dispose = createRoot((dispose) => {
  createEffect(() => console.log(count()));
  return dispose;
});

dispose(); // the effect stops

createRoot creates a scope that is not disposed with its parent. Use it for long-lived stores or for code outside components.

Context

tsx
const Theme = createContext<'light' | 'dark'>('light');

function App() {
  return <Theme.Provider value="dark">{() => <Toolbar />}</Theme.Provider>;
}

function Toolbar() {
  const theme = useContext(Theme); // 'dark'
  return <div class={theme}>…</div>;
}

Pass the Provider's children as a function so they are created inside it (see Components and JSX).

Lifecycle

onMount(fn) runs once after the component's nodes are created (in a microtask), inside the component's owner, so onCleanup works in it. It is skipped if the component is disposed first.

tsx
function Chart() {
  let canvas!: HTMLCanvasElement;
  onMount(() => {
    const chart = drawChart(canvas);
    onCleanup(() => chart.destroy());
  });
  return <canvas ref={(el) => (canvas = el)} />;
}