The mental model
A component runs once. What runs again is the small piece of code around each place that reads a signal.
Show the code
import { useEffect, useStore } from "@shadow-js/core";
export function Counter() {
const [count, setCount] = useStore(0);
const [effectRuns, setEffectRuns] = useStore(0);
let bodyRuns = 0;
bodyRuns += 1;
useEffect(() => {
count();
setEffectRuns((n) => n + 1);
});
return (
<div>
<button type="button" onClick={() => setCount((n) => n + 1)}>
Clicked {count()}
</button>
<p>
Component body runs: {bodyRuns}. Effect runs: {effectRuns()}.
</p>
</div>
);
}
Click the button. The effect runs again, because it reads count. The body's counter never moves: a component function is called once, when it is created, and never again for that instance. Writing a signal re-renders nothing.
| React | ShadowJS | |
|---|---|---|
| The component function | Runs on every state change of the component or its parents. | Runs once per instance. |
| State | A value per render: const [n, setN] = useState(0). |
A function that returns the current value: count(). |
| An effect's dependencies | An array you maintain. | Whatever the effect read last time. |
| An update | The component's subtree, compared. | The one text node or attribute that read the signal. |
Signals are functions#
useStore(0) returns a read function and a write function. Calling the read function returns the value and, when something is tracking, subscribes it. Effects, memos and the expressions in your JSX track. A component body does not, so a read there is a plain read. untrack reads without subscribing anywhere, and event handlers and onMount callbacks are untracked for you.
import { createRoot, flushSync, untrack, useEffect, useStore } from "@shadow-js/core";
const [count, setCount] = useStore(0);
createRoot((dispose) => {
useEffect(() => {
console.log(`tracked read: ${count()}`);
});
useEffect(() => {
console.log(`untracked read: ${untrack(count)}`);
});
flushSync();
setCount(1);
flushSync();
dispose();
});
tracked read: 0
untracked read: 0
tracked read: 1Both effects ran at the first flush. After the write, only the one that subscribed ran again.
Because a signal is a plain function, you can pass it around. {count} and {count()} in JSX are both live: the first hands the function to the renderer, the second lets the compiler wrap the call.
The compiler writes the updates#
The static part of your markup becomes one HTML string that is cloned once per instance. Each dynamic place, a {expression}, an attribute or a <Show>, becomes one binding that re-runs when what it read changes. The browser has nothing else to do when data changes. The compiler shows the output for real components.
Props are getters#
A prop that is not a constant reaches the child as a getter. Reading props.name runs the parent's expression at that moment, in whatever tracking context the child is in. So a parent passes a way to compute a value, and the child decides when to ask.
Reading a prop once in the body, outside any binding, would capture that moment. The compiler prevents that stale read in the cases that are common; Forgiving reactivity lists them.
What runs, and when#
| Code | Runs | Runs again when |
|---|---|---|
| The component function | Once per instance | Its region creates a new instance. |
| Statements in the body, outside a binding | Once | Never. |
{count()} or any dynamic JSX expression |
At creation | A signal it read changes. Only this expression. |
An attribute, class or style binding |
At creation | A signal it read changes. Only this attribute. |
useMemo(fn) |
At creation | A dependency changed. The result is cached. |
useEffect(fn) |
In the first flush | A dependency changed. Never on the server. |
onMount(fn) |
In the first flush | Never. |
| An event handler | When the event fires | Its expression is evaluated once, not re-bound. |
The statements row surprises people: if (count() > 3) { ... } at the top of a component executes once. Put reactive logic in JSX, a memo or an effect.
When updates arrive#
A write marks its readers stale and schedules a flush in a microtask. Reads are always fresh: right after setCount(1), count() returns 1. Bindings and effects catch up at the flush, and three things flush earlier: the end of an event handler, batch(fn) and flushSync(). Two writes in the same tick cost one update.
Next#
State is the first of the short pages that put each idea in your hands. The deeper reading is Forgiving reactivity and the Reactivity model.