Advanced Hooks
Beyond state, effect and ref, the runtime ships the rest
of the React hook set under pp.*. The API matches React closely, but a few
semantics differ because PulsePoint renders synchronously and a component's fallback
markup lives in the same template. Those differences are called out below.
Rules every hook follows
- Call hooks at the top level of the component script, in the same order on every render. The runtime tracks hook types per slot and warns when the order changes.
- Dependencies must be an array or omitted. Anything else logs
[PP-WARN] … dependencies … must be an arrayand is treated as omitted. - Deps compare with
Object.is, so memoize object and function deps first. - A setter that receives the value already in state (
Object.is) does nothing. Several setter calls in one tick are batched into one render. - Effects return a cleanup function or nothing. An
asynceffect returns a promise, which is ignored with a[PP-WARN] … returned a Promise. Start async work inside a synchronous effect instead. - Setters called after the component was destroyed are ignored. That makes a late
pp.rpcresponse safe, but it is not a reason to skip effect cleanup.
pp.memo and pp.callback
A child component re-renders when one of its props changes identity. An inline
rows="{list.filter(...)}" or on-select="{(r) => …}" is a new value on
every parent render, so the child re-renders every time. Memoize those values, then pass them by name:
<div pp-component="order_table">
<input value="{filter}" oninput="setFilter(target.value)" />
<!-- Nested component: its attributes become pp.props -->
<div pp-component="order_rows" rows="{visible}" on-select="{select}">…</div>
<script>
const [orders] = pp.state(pp.props.orders ?? []);
const [filter, setFilter] = pp.state("");
// Same array identity until orders/filter change → the child is not re-rendered.
const visible = pp.memo(
() => orders.filter((o) => o.customer.includes(filter)),
[orders, filter],
);
// Same function identity for the life of the component.
const select = pp.callback((row) => console.log("picked", row.id), []);
</script>
</div>
pp.reducer with lazy init
Besides pp.reducer(reducer, initialState) (see State),
a third argument computes the initial state once from the second:
const [cart, dispatch] = pp.reducer(cartReducer, pp.props.savedCart, (saved) =>
saved ? JSON.parse(saved) : { items: [], total: 0 }, // init(arg) runs once
);
pp.state(() => expensive()) works the same way: a function passed as the initial value runs once.
pp.layoutEffect
Runs synchronously after the DOM is patched and before pp.effect. A state
update inside it re-renders before the browser paints. Use it for measurement and
positioning. Use pp.effect for everything else, because layout effects block paint.
<div pp-component="tooltip">
<span pp-ref="anchor">Hover me</span>
<div pp-ref="tip" class="tip" pp-style="{position}">Tip</div>
<script>
const anchor = pp.ref(null);
const tip = pp.ref(null);
const [position, setPosition] = pp.state("");
// Measure after the DOM is updated but before the browser paints,
// so the tip never flashes at the wrong spot.
pp.layoutEffect(() => {
const box = anchor.current.getBoundingClientRect();
setPosition(`top: ${box.bottom + 4}px; left: ${box.left}px;`);
}, []);
</script>
</div>
pp.id
A DOM-safe id built from the component id and the hook's position:
pp-<componentId>-<slot>. It stays the same across re-renders, and two instances
never collide. Use it to pair label for, aria-describedby and similar
attributes instead of counters or loop indexes:
<div pp-component="email_field">
<label for="{emailId}">Email</label>
<input id="{emailId}" aria-describedby="{hintId}" name="email" />
<p id="{hintId}">We never share it.</p>
<script>
const emailId = pp.id(); // "pp-email_field-0", stable across renders
const hintId = pp.id(); // "pp-email_field-1"
</script>
</div>
pp.syncExternalStore
Reads a source PulsePoint does not own (media queries, localStorage, a global
store, a socket's latest message) and re-renders when its snapshot changes. It
re-reads once right after subscribing, so a change that happens between render and
subscription is not lost. getSnapshot must return the same value
(Object.is) while nothing has changed, so return primitives or cached objects.
<div pp-component="theme_badge">
<span>{dark ? "Dark mode" : "Light mode"}</span>
<script>
const query = window.matchMedia("(prefers-color-scheme: dark)");
// subscribe MUST be stable, or the store is resubscribed every render.
const subscribe = pp.callback((onChange) => {
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const dark = pp.syncExternalStore(subscribe, () => query.matches);
</script>
</div>
pp.imperativeHandle
Lets a child expose methods such as open() or focus() instead of its raw
DOM node. The parent passes its own pp.ref as an ordinary prop, and the child
publishes the handle on it. The handle is set in a layout effect and cleared to
null when the child unmounts. A callback ref works too: it receives the
handle, then null.
<!-- Child: publishes an API on the ref its parent passed in -->
<div pp-component="confirm_dialog" control-ref="{dialogApi}" hidden="{!open}">
<p>Are you sure?</p>
<button onclick="setOpen(false)">Close</button>
<script>
const [open, setOpen] = pp.state(false);
pp.imperativeHandle(pp.props.controlRef, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}), []);
</script>
</div>
<!-- Parent: owns the ref and calls the child's API -->
<script>
const dialogApi = pp.ref(null);
</script>
<button onclick="dialogApi.current?.open()">Delete</button>
For a plain DOM node rather than an API, use pp-ref on the child's tag instead.
See ref forwarding.
pp.transition
Returns [isPending, startTransition]. isPending stays true while
any started scope is running. For a scope that returns a promise, that means until the
promise settles. It does not deprioritize rendering the way React's
concurrent mode does, because PulsePoint renders synchronously. Use it for the pending
flag, which is what transitions are mostly used for:
<div pp-component="save_button">
<button onclick="save()" disabled="{saving}">{saving ? "Saving…" : "Save"}</button>
<script>
const [saving, startTransition] = pp.transition();
// isPending stays true until the returned promise settles.
const save = () => startTransition(() => pp.rpc("saveDraft", { text: pp.props.text }));
</script>
</div>
pp.deferredValue
Returns a copy of value that catches up one commit later, updated from an
effect. The input stays responsive while an expensive child receives the value a
beat behind. An optional second argument sets the value used on the first render.
<div pp-component="product_filter">
<input value="{query}" oninput="setQuery(target.value)" />
<p hidden="{query === deferredQuery}">Updating…</p>
<div pp-component="product_grid" query="{deferredQuery}">…expensive list…</div>
<script>
const [query, setQuery] = pp.state("");
// The input repaints on every key; the grid gets the value one commit later.
const deferredQuery = pp.deferredValue(query);
</script>
</div>
pp.optimistic
Shows the expected result before the server confirms it.
pp.optimistic(passthrough, reducer?) returns the passthrough value with every
pending action applied through the reducer. With no reducer, the latest action simply
replaces the value. When passthrough changes, usually because you stored the
server's answer, all pending actions are dropped in the same render, so a stale guess
never flashes.
<div pp-component="like_button">
<button onclick="like()">♥ {shown.likes}</button>
<script>
const [post, setPost] = pp.state({ likes: Number(pp.props.likes ?? 0) });
const [shown, addOptimistic] = pp.optimistic(post, (current, delta) => ({
...current,
likes: current.likes + delta,
}));
const like = async () => {
addOptimistic(1); // shows likes + 1 immediately
try {
const confirmed = await pp.rpc("like", { postId: pp.props.postId });
setPost({ likes: confirmed.likes }); // new base → pending guesses dropped
} catch {
setPost({ ...post }); // same data, new identity → guess dropped
}
};
</script>
</div>
Pending actions are dropped only when passthrough changes identity
(Object.is). If the call fails, nothing changes the base and the guess
stays on screen. Keep the base in an object, as above, so the catch can
drop the guess by setting a fresh copy. With a primitive base, setting the same
number again would be ignored.
pp.errorBoundary
Calling pp.errorBoundary() makes the component a boundary and returns
[error, reset]. How it differs from React:
- It also catches its own throws. A throw from the boundary's own render, effects or cleanups is caught too, because the fallback markup lives in the same template. A component without a boundary passes the error to the nearest ancestor that has one.
- It latches. The error stays set until you call
reset(); a later successful render does not clear it. - It gives up after five catches. After five errors without a
reset(), the boundary stops catching, so a fallback that itself throws cannot loop forever.reset()re-arms it. - Event handlers and async code are not covered, same as React. Wrap
onclickbodies and awaitedpp.rpccalls intry/catch. - An error no boundary catches is logged as
[PP-ERROR] Render Cycle Failed.
<section pp-component="report_panel">
<div hidden="{!error}" role="alert">
<p>This panel failed: {error?.message}</p>
<button onclick="reset()">Try again</button>
</div>
<div hidden="{!!error}">
<!-- A throw inside this nested component lands in the boundary above -->
<div pp-component="revenue_chart" data="{rows}">…</div>
</div>
<script>
const [error, reset] = pp.errorBoundary();
const [rows] = pp.state(pp.props.rows ?? []);
</script>
</section>