State
pp.state(initial) declares component-scoped reactive state. It returns a [value, setter] tuple, and every setter call re-renders exactly the parts of the component that changed.
Basics
<div pp-component="counter_1">
<p>Count: {count}</p>
<button onclick="setCount(count + 1)">+1</button>
<button onclick="setCount((c) => c + 1)">+1 (updater)</button>
<button onclick="setCount(0)">Reset</button>
<script>
const [count, setCount] = pp.state(0);
</script>
</div>
- Setters accept a plain value or an updater function
(prev) => next. Use the updater form whenever the next value derives from the previous one. - State lives per component instance. Two instances of the same component never share state.
- Top-level destructured bindings (
count,setCount) are exported to the template automatically.
Objects and arrays
State updates replace the value — there is no merge. Spread the previous value when updating one field:
<div pp-component="profile_form">
<input value="{form.name}" oninput="update('name', target.value)" />
<input value="{form.email}" oninput="update('email', target.value)" />
<p hidden="{!form.name}">Hello, {form.name}!</p>
<script>
const [form, setForm] = pp.state({ name: "", email: "" });
// State is replaced, not merged — spread the previous value.
const update = (field, value) =>
setForm((prev) => ({ ...prev, [field]: value }));
</script>
</div>
Controlled vs. uncontrolled form fields
A form control is controlled or uncontrolled for its whole lifetime.
value="{state}" / checked="{state}" is the controlled
form; the lowercase attributes defaultvalue / defaultchecked
seed an uncontrolled field once and then leave the user's typing alone.
<!-- Controlled: state owns the value for the element's lifetime -->
<input value="{query}" oninput="setQuery(target.value)" />
<!-- Uncontrolled: seeded once, then the DOM owns it -->
<input defaultvalue="{initialName}" name="name" />
Binding value to state that starts undefined flips the element
from uncontrolled to controlled mid-life and logs
[PP-WARN] changed from uncontrolled to controlled. Fix the initial
state (pp.state(""), not pp.state()) — never add both attributes.
Reducer state
For multi-field update logic, pp.reducer keeps transitions in one place:
<div pp-component="cart_widget">
<p>{cart.count} items — total {cart.total}</p>
<button onclick="dispatch({ type: 'add', price: 10 })">Add</button>
<button onclick="dispatch({ type: 'clear' })">Clear</button>
<script>
const [cart, dispatch] = pp.reducer((state, action) => {
if (action.type === "add") {
return { count: state.count + 1, total: state.total + action.price };
}
if (action.type === "clear") return { count: 0, total: 0 };
return state;
}, { count: 0, total: 0 });
</script>
</div>
When NOT to use state
State means "render required". A value whose change should not repaint
anything — a timer id, a request generation counter, a pagination cursor,
transient query text — belongs in pp.ref.
Keeping non-visual values out of state is the single biggest PulsePoint
performance lever.