[]previous and next page

The limits

The limits in the Forgiving reactivity guide.

For agentsUpdated

These rules keep the feature predictable. Every limit has a program that shows the consequence and the fix.

A derived value is a memo#

It re-runs only when a signal it read changed, and it caches. Reading it in ten places evaluates it once.

src/cached.tsxtsx
import { flushSync, useStore } from "@shadow-js/core";
import { render } from "@shadow-js/web";

const [count, setCount] = useStore(0);
let evaluations = 0;

function parityOf(value: number): number {
  evaluations += 1;
  return value % 2;
}

function Cached() {
  const parity = parityOf(count());
  return (
    <p>
      {parity} {parity} {parity}
    </p>
  );
}

const host = document.createElement("div");
const dispose = render(() => <Cached />, host);
console.log(evaluations);
setCount(1);
flushSync();
console.log(evaluations);
dispose();
Expected output
1
2

Only const, only at the top level of the body#

A let, or a constant declared inside a block, is not rewritten. It is evaluated once, when the component runs. Make it a const at the top level, or use useMemo.

src/const-only.tsxtsx
import { flushSync, useStore } from "@shadow-js/core";
import { render } from "@shadow-js/web";

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

function Compare() {
  let stale = count() * 2;
  const live = count() * 2;
  return (
    <p>
      {stale} {live}
    </p>
  );
}

const host = document.createElement("div");
const dispose = render(() => <Compare />, host);
console.log(host.textContent);
setCount(1);
flushSync();
console.log(host.textContent);
dispose();
Expected output
0 0
0 2

Names that are re-declared below the top level#

A name that is declared again anywhere inside the component (a shadowing parameter, a block const) is not rewritten, and neither is the outer constant of that name. The outer one is then evaluated once. Rename the inner binding.

src/shadowing.tsxtsx
import { flushSync, useStore } from "@shadow-js/core";
import { render } from "@shadow-js/web";

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

function Shadowed() {
  const double = count() * 2;
  const rows = [0].map((double) => double);
  return (
    <p>
      {double} {rows}
    </p>
  );
}

const host = document.createElement("div");
const dispose = render(() => <Shadowed />, host);
console.log(host.textContent);
setCount(1);
flushSync();
console.log(host.textContent);
dispose();
Expected output
0 0
0 0

What is left alone#

Hooks and primitives (names starting use or create followed by a capital letter, and batch, untrack, onMount, onCleanup, onError, catchError, mapArray, indexArray, mergeProps, splitProps, children, lazy, render, hydrate, flushSync, runWithOwner, getOwner), functions, classes, JSX, literals, await, and any constant that is not read in JSX or inside a closure. A constant whose expression has no call, member access, optional chain, tagged template or in test, and reads no derived constant, is a plain constant: const total = 1 + 2 costs nothing.

Components are recognized by name. A function that is not PascalCase is not a component, so nothing inside it is rewritten:

src/not-a-component.tsxtsx
import { flushSync, useStore } from "@shadow-js/core";
import { render } from "@shadow-js/web";

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

function counter() {
  const double = count() * 2;
  return <p>{double}</p>;
}

const host = document.createElement("div");
const dispose = render(() => counter(), host);
console.log(host.textContent);
setCount(1);
flushSync();
console.log(host.textContent);
dispose();
Expected output
0
0

Early returns are not live#

A returned conditional is made live only when a branch contains JSX and the condition reads something (a call, a member such as props.open, or a derived constant). An early return is left exactly as written: the component runs once, takes one branch, and stays on it. Use <Show> (or a single returned ternary) for that.

src/early-return.tsxtsx
import { flushSync, useStore } from "@shadow-js/core";
import { render, Show } from "@shadow-js/web";

const [open, setOpen] = useStore(true);

function Early() {
  if (open()) return <b>open</b>;
  return <i>closed</i>;
}

function Guarded() {
  return (
    <Show when={open()} fallback={<i>closed</i>}>
      <b>open</b>
    </Show>
  );
}

const early = document.createElement("div");
const guarded = document.createElement("div");
const disposeEarly = render(() => <Early />, early);
const disposeGuarded = render(() => <Guarded />, guarded);
console.log(`${early.textContent}|${guarded.textContent}`);
setOpen(false);
flushSync();
console.log(`${early.textContent}|${guarded.textContent}`);
disposeEarly();
disposeGuarded();
Expected output
open|open
open|closed

A constant condition is never wrapped. A comment /*@static*/ immediately before the condition opts a returned conditional out explicitly: it is read once.

src/static.tsxtsx
import { flushSync, useStore } from "@shadow-js/core";
import { render } from "@shadow-js/web";

const [open, setOpen] = useStore(true);

function Frozen() {
  return /*@static*/ open() ? <b>open</b> : <i>closed</i>;
}

const host = document.createElement("div");
const dispose = render(() => <Frozen />, host);
console.log(host.textContent);
setOpen(false);
flushSync();
console.log(host.textContent);
dispose();
Expected output
open
open

Switching it off#

autoReactive: false in compiler options, or shadow({ autoReactive: false }) in Vite, disables the rules above for the file or project. The compiler then keeps derived constants as ordinary JavaScript constants.