[]previous and next page

The mental model

A component runs once. What runs again is the small piece of code around each place that reads a signal.

For agentsUpdated
Show the code
Counter.tsxtsx
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.

src/tracking.tsts
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();
});
Expected output
tracked read: 0
untracked read: 0
tracked read: 1

Both 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.