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. - An effect that sets state on every run (no dependency array, or a dependency that changes every render) is stopped after 50 renders in a row with
[PP-WARN] Maximum update depth exceeded, instead of freezing the page. - When a subtree is removed, every layout-effect cleanup in it runs before any passive-effect cleanup, as in React.
- 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.event
pp.event(fn) returns a function whose identity never changes and which always
runs the version of fn from the latest render. It is React's
useEffectEvent, without the rule that it may only be called from effects.
It solves stale closures. A handler given once to something outside the render cycle
(a socket's onMessage, a library callback, a listener added in a []
effect) keeps the function it was given, and a plain function still sees the state of
the render that created it. The stable function reads current state, and since its
identity never changes it can be handed out once or listed in a dependency array
without re-running the effect:
<div pp-component="chat_room">
<p>Unread: {unread}</p>
<script>
const [unread, setUnread] = pp.state(0);
const [muted, setMuted] = pp.state(false);
// Always runs with the latest muted/unread, but its identity never changes.
const onMessage = pp.event((msg) => {
if (!muted) setUnread(unread + 1);
});
// Opened once: the socket keeps the stable function, so it never goes stale
// and listing it as a dependency does not reopen the connection.
pp.effect(() => {
const socket = pp.socket("room", { id: pp.props.roomId }, { onMessage });
return () => socket.close();
}, [onMessage]);
</script>
</div>
Use pp.callback when a child should re-render because the function changed;
use pp.event when nothing should re-run, only the latest logic should.
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.
On mount the runtime reads the snapshot twice and logs
[PP-WARN] pp.syncExternalStore getSnapshot … returned a different value when the
two reads differ, because such a store would re-render on every notification.
<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. A guess added inside a pp.transition() scope is also dropped
when that transition settles, as in React, so a failed request rolls back on its own.
<div pp-component="like_button">
<button onclick="like()" disabled="{saving}">♥ {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 [saving, startTransition] = pp.transition();
const like = () =>
startTransition(async () => {
addOptimistic(1); // shows likes + 1 immediately
const confirmed = await pp.rpc("like", { postId: pp.props.postId });
setPost({ likes: confirmed.likes }); // new base → guess replaced
}); // settles (even on failure) → guess dropped
</script>
</div>
- Inside a transition (before or after its
awaits), a guess lasts until the base changes or the transition settles, whichever comes first. A guess added after anawaitis tied to every transition still in flight and dropped once the last of them settles. - Outside a transition, a guess is dropped only when
passthroughchanges identity (Object.is). If the call fails, nothing changes the base and the guess stays on screen; drop it by setting a fresh copy of an object base in thecatch. Prefer the transition form.
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.
- A broken fallback goes up. If the boundary throws again while it is rendering its fallback (an error is already set), that error goes to the next boundary up, as in React, instead of re-rendering the same failing fallback.
- 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>