[]previous and next page

useEffect and dependency arrays

useEffect(fn) runs fn after the component is created and again whenever a signal that fn read synchronously changes.

For agentsUpdated

useEffect(fn) runs fn after the component is created and again whenever a signal that fn read synchronously changes. There is no dependency array. A return value that is a function is the cleanup; it runs before the next run and when the owner is disposed.

React keeps a dependency array so an effect sees fresh values; without it, a closure captured on one render keeps that render's values forever. In ShadowJS a closure reads the accessor when it is called, so it always sees the latest value, and the effect re-runs only for what it read at its top level.

This is the stale-closure bug in React:

tsx
function Ticker({ source }: { source: Source }) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    // `count` was captured when the effect ran: this logs 0 forever.
    return source.subscribe(() => console.log("count is", count));
  }, []); // adding `count` fixes the value but resubscribes on every click
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

And the ShadowJS version. The listener calls count() when the source fires, so it reads the current value, and the subscription is made once.

src/ticker.tsxtsx
import { useEffect, useStore } from "@shadow-js/core";

export interface Source {
  subscribe(listener: () => void): () => void;
}

export function Ticker(props: { source: Source; onTick: (count: number) => void }) {
  const [count, setCount] = useStore(0);
  useEffect(() => props.source.subscribe(() => props.onTick(count())));
  return (
    <button type="button" onClick={() => setCount((n) => n + 1)}>
      {count()}
    </button>
  );
}

These forms cover what dependency arrays did:

Intent React ShadowJS
Re-run when what I read changes useEffect(fn, [a, b]) useEffect(() => { a(); b(); ... })
Re-run on one trigger only; do not track the rest useEffect(fn, [id]) useEffect(id, (value, previous) => { ... }): only id is tracked, the second function runs untracked
Skip the first run An isFirst ref useEffect(id, fn, { defer: true })
Run once on mount useEffect(fn, []) onMount(fn); it runs untracked, a returned function is the cleanup
Cleanup The returned function The returned function, or onCleanup(fn) anywhere inside the owner

Timing and useLayoutEffect#

useRenderEffect is the useLayoutEffect of ShadowJS: it runs in the render phase, before user effects. Most code needs neither. Writes are batched and flushed in a microtask (or at the end of an event handler, or by flushSync()); in one flush the render effects that update the DOM run first and user effects run after them. An effect therefore already sees the updated DOM, and a microtask runs before the browser paints.

src/title-effect.tsxtsx
import { useEffect, useStore } from "@shadow-js/core";

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

export function Measured() {
  useEffect(() => {
    const value = count();
    document.body.dataset["seen"] = `${value}:${document.querySelector("p")?.textContent ?? ""}`;
  });
  return <p>{count()}</p>;
}

Effects never run on the server. useEffect, onMount and useRenderEffect return without creating anything under renderToString, so server output never depends on them:

src/server-effect.tsxtsx
import { useEffect } from "@shadow-js/core";
import { renderToString } from "@shadow-js/web/server";

function Page() {
  console.log("render");
  useEffect(() => {
    console.log("effect");
  });
  return <p>hello</p>;
}

renderToString(() => <Page />);
Expected output
render