State & Hooks
PulsePoint is a component runtime, not a DOM helper. Every component root owns a plain
<script>
that declares state, effects, refs and handlers — the surrounding HTML is its template.
The right mental model is function component + hooks + HTML.
Browser state and server state are different things
pp.state lives in the browser, scoped to one component instance, and drives rendering.
The PHP-side StateManager lives on the server and shares
values between PHP files during a single render. They never see each other.
When the server needs a value the browser holds, send it explicitly with pp.rpc(...). That call is the boundary, and it is where validation belongs.
The shape of a component
One parent element is the boundary. The markup is the template. A single untyped
<script>
is the last child. Prisma PHP injects the pp-component attribute for you — never write it yourself
in a route, layout or imported partial.
<div>
<h2>{title}</h2>
<p>Count: {count}</p>
<button onclick="setCount(count + 1)">Increment</button>
<button onclick="reset()">Reset</button>
<script>
const { title } = pp.props;
const [count, setCount] = pp.state(0);
function reset() {
setCount(0);
}
</script>
</div>
What the template can see
An AST pass exports top-level bindings from the script into the template scope. You never write
return yourself — the runtime appends that step.
Exported automatically
- Top-level
function,async functionandclassdeclarations - Top-level
const/let/varidentifiers - Top-level object destructuring, e.g. reading two props off
pp.props - Top-level array destructuring, e.g. the pair returned by
pp.state(...)— any initializer works, not just hooks
Not exported
- Anything declared inside a function, condition, loop or callback
- Loop variables and callback locals
- Values you only assign to, never declare, at the top level
If the template cannot see a value, the usual cause is that its declaration is nested one level too deep.
Hook reference
Available on pp inside a component script.
| Hook | Returns | Notes |
|---|---|---|
| pp.state(initial) | [value, setValue] | Setters accept a value or an updater function. Passing a function as the initial value makes it a lazy initializer that runs once. |
| pp.effect(cb, deps?) | cleanup? | Runs after render. May return a cleanup function. |
| pp.layoutEffect(cb, deps?) | cleanup? | Runs synchronously after DOM mutation, before paint. |
| pp.ref(initial?) | an object with .current | Pair with the pp-ref attribute to reach a DOM node. |
| pp.memo(factory, deps) | value | Memoizes a computed value. |
| pp.callback(fn, deps) | fn | Memoizes a function identity. |
| pp.reducer(reducer, initial, init?) | [state, dispatch] | Supports an optional initializer function. |
| pp.context(token) | value | Resolves through ancestor providers, falling back to the token default. |
| pp.portal(ref, target?) | an object with .sourceParent | Physically moves the element (default target document.body) and leaves a comment placeholder behind. |
| pp.id() | string | A stable unique id for this component instance. |
| pp.errorBoundary() | handle | The runtime's only error-containment mechanism. Latches after 5 captured errors until reset() is called. |
| pp.syncExternalStore(sub, get) | snapshot | Subscribes the component to an external store. |
| pp.imperativeHandle(ref, create, deps?) | — | Exposes an imperative API on a ref. Pair with pp-ref-forward="true" on the boundary. |
| pp.transition() | [isPending, startTransition] | Marks updates as non-urgent. |
| pp.deferredValue(value, initial?) | value | A deferred copy of a value. |
| pp.optimistic(passthrough, reducer?) | [value, addOptimistic] | Optimistic UI updates while a request is in flight. |
| pp.props | object | The current prop bag. children is injected into it and holds the root's initial inner HTML. |
Effects and cleanup
<script>
const [width, setWidth] = pp.state(window.innerWidth);
pp.effect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
</script>
Never make an effect async
pp.effect and pp.layoutEffect are cleanup-style hooks,
not promise runners. A returned Promise is discarded and logs a
[PP-WARN] ... returned a Promise warning — and your cleanup never runs.
// wrong
pp.effect(async () => { await load(); }, []);
// right
pp.effect(() => { load(); }, []);
Refs and portals
<div>
<input pp-ref="nameInput" />
<button onclick="nameInput.current?.focus()">Focus</button>
<script>
const nameInput = pp.ref(null);
</script>
</div>
<div>
<div pp-ref="dialog" class="modal">...</div>
<script>
const dialog = pp.ref(null);
// moves the node to document.body and
// leaves a comment placeholder behind
pp.portal(dialog);
</script>
</div>
.current refs are supported. Use
pp-ref="registerRef(id)" wrapped in braces when you need a dynamic ref expression.
The runtime writes data-pp-ref internally — do not author it.
Context
Create a token with pp.createContext(defaultValue), provide it in the template with
<Token.Provider value="...">, and read it from a descendant script with
pp.context(token). Resolution walks the component parent chain and falls back to the token default.
<section>
<ThemeContext.Provider value="{theme}">
<div theme-context="{ThemeContext}">
<p>{resolvedTheme}</p>
<script>
const { themeContext } = pp.props;
const resolvedTheme = pp.context(themeContext);
</script>
</div>
</ThemeContext.Provider>
<script>
const ThemeContext = pp.createContext('light');
const [theme] = pp.state('dark');
</script>
</section>
theme-context attribute above is doing.
Context is provided through the token's Provider tag in the template and read with
pp.context(token) in the script — those two are the whole API.
Rules that bite
No type on component scripts
PulsePoint recognises a <script> with no
type attribute at all. Give it any type and the runtime
treats it as an ordinary browser script instead of component logic.
No bootstrap wrappers
Write bindings at the top level. No DOMContentLoaded,
no IIFE, no manual pp.mount() — the shipped bundle mounts itself and repeat calls are no-ops.
Plain JavaScript, not modules
No import or export
inside a component script. Reusable module code belongs in the ts/ directory and reaches the
component as a registered global.
One instance per pp-component
The attribute is an instance id, not a class name. State, scope, templates and parent tracking are keyed by it, so duplicates are unsafe. Prisma PHP generates unique values for you.