useEffect and dependency arrays
useEffect(fn) runs fn after the component is created and again whenever a signal that fn read synchronously changes.
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:
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.
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.
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:
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 />);
render