The limits
The limits in the Forgiving reactivity guide.
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.
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();
1
2Only 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.
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();
0 0
0 2Names 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.
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();
0 0
0 0What 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:
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();
0
0Early 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.
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();
open|open
open|closedA constant condition is never wrapped. A comment /*@static*/ immediately before the condition opts a returned
conditional out explicitly: it is read once.
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();
open
openSwitching 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.