<state> · <computed> local reactive values
- Attributes
<state name :value>·<computed name from>- Semantics
- state is mutated only by
<set>/bind:; computed is pure and recomputed from dependencies. - Level
- L1
Reactivity is a declared dependency graph in markup: local state, derived values, and external resources, each with a distinct lifecycle. The graph is statically analyzable, so it lowers to React state, Vue refs, Svelte runes, or a signal-based browser runtime: one semantics, many backends, no eval().
HTML Next keeps a few concepts separate rather than overloading one element, because their lifecycles differ. All of them are declarations: they live in the definition's <defs> region, not in the visible markup (see Components).
| Element | Is | Written by |
|---|---|---|
<state name :value> | a local mutable value | <set> in a handler, or bind: |
<computed name from> | a pure value derived from others | nothing, recomputed from its dependencies |
<data name src> | a resource read from a source | its params; refetched reactively |
<state name="query" value=""> <!-- literal string -->
<state name="page" :value="1"> <!-- number, via the colon -->
<computed name="hasQuery" from="query != ''"> <!-- derived boolean -->The design, a declared dependency graph that can be analyzed statically rather than traced at runtime, has deep prior art in signals and fine-grained reactivity5: Solid signals and createMemo and Angular signals are its closest current relatives, with Knockout observables as the historical ancestor. RxJS is a deliberate contrast, it models push streams, not the settled value cells <state> and <computed> are.
<set> mirrors <state> exactly: same name, same :value. A plain attribute is a string; the : prefix makes the value a typed expression. :value="false" is the boolean, value="false" the five-character string.
This is HTML Next's standards-shaped answer to htmx1: instead of hx-get/hx-trigger/hx-target string attributes swapping opaque HTML, a <data> element declares a typed, reactive resource whose parameters are visible right where it lives. Each <param :value> subscribes to the state it binds, so the set of params is the dependency graph: change one and the resource refetches, and bindings that read it re-render. Nothing triggers it imperatively. This is the surface Solid createResource already ships6, a resource whose fetch re-runs on source change and exposes loading and error; TanStack and Vue Query are the same idea with a params-keyed cache.
<data name="search" src="/api/search" type="SearchResults" debounce="200ms">
<param name="q" :value="query"> <!-- subscribes to query: refetches when it changes -->
<param name="page" :value="page">
</data>Params are serialized, never interpolated into a URL string. A {name} placeholder in src is filled from the matching <param> (RFC 6570 URI Templates)2; params not named in the template become the query string. There is no string concatenation, and therefore no injection surface.
A <data> exposes a small, typed surface any binding can read:
| Path | Meaning |
|---|---|
search.pending | a request is in flight |
search.value | the resolved value (typed as type) |
search.error | the failure, if any |
search.ok | settled with a value and no error |
To refetch with unchanged inputs (a manual refresh, polling) there is no imperative call: bump a state param the source depends on, or declare a poll interval. A param change cancels any in-flight request, and requests are keyed by their resolved params, giving a natural cache key.
<input bind:value="query" placeholder="Search…">
<template $match>
<progress $when="search.pending"></progress>
<output $when="search.error"><value of="search.error.message"></value></output>
<ul $else>
<li $each="r of search.value.results" $key="r.id"><value of="r.title"></value></li>
</ul>
</template>Reactivity covers reads completely, but a write has an irreducible moment, the user commits, and you do not POST on every keystroke. The platform already has the declarative write primitive: form submission3. A write is a <form> with the same param model as <data>, async-enhanced (no full-page navigation), exposing the identical .pending/.error surface. The only difference from a read is when it sends: a read sends when a param changes, a write sends on submit.
<!-- a write is the native form primitive, async-enhanced -->
<form name="save" method="post" src="/api/posts/{id}" on:success="afterSave">
<param name="id" :value="post.id"> <!-- {id} → path -->
<input name="title" bind:value="draft.title"> <!-- a control → body -->
<param name="tags" :value="draft.tags"> <!-- no control → body -->
<button>Publish</button>
</form>
<!-- save.pending / save.error read exactly like a <data> source -->
<p aria-live="polite" $if="save.pending">Saving…</p>Named inputs and <param> serialize the same way, routed by method: to the query string for GET, to the body for POST/PUT/PATCH, exactly as a native form already does. Post-write side effects (leave edit mode, announce success) belong to the form's lifecycle, so they run from an on:success handler, not inline.
Settled: reactive reads, form writes, param-driven dependency tracking. Still open: pagination accumulation, optimistic updates, revalidation policy, request headers/auth, and real-time push (SSE/WebSocket) where the server drives change without a param bump. Outside-world reactions (timers, subscriptions) are now the JavaScript layer's job, see Lifecycle below.
In a reactive component most of what framework lifecycle callbacks did is absorbed by the dependency graph, so HTML Next needs far fewer hooks, and names the ones it keeps after the platform's custom-element reactions, for least surprise.
<computed>, and <data> that read a prop re-run automatically. This is attributeChangedCallback, and you never write it.<data>; it runs when its params resolve and again when they change. This is the connectedCallback fetch.<state :value> and <computed>; initial focus is autofocus.For a reaction that is not a derivation, on:connect and on:disconnect run a handler when the component is connected or disconnected, mirroring connectedCallback/disconnectedCallback7 and firing again on reconnect. Because handlers are declarative, they set state or <dispatch> an event, with no imperative code. They are client-only: SSR renders the static tree, and hydration is what connects, so nothing lifecycle-driven runs on the server.
<defs>
<state name="visible" :value="false">
<handler name="show"><set name="visible" :value="true"></handler>
<handler name="hide"><set name="visible" :value="false"></handler>
</defs>
<!-- lifecycle events run handlers; client-only (SSR never connects) -->
<section on:connect="show" on:disconnect="hide">…</section>| Web Components reaction | HTML Next |
|---|---|
constructor | none, the <template component> declaration is the definition |
connectedCallback | on:connect (declarative) · the JS behavior's connect (imperative, below) |
disconnectedCallback | on:disconnect · the JS behavior's teardown |
attributeChangedCallback | not written, reactivity re-runs the dependents |
adoptedCallback | on:adopt (rare, cross-document moves) |
| form-associated callbacks | the forms & validation story (see Validation) |
Framework developers will read this as mount / unmount (React, Vue, Svelte), and that is the right analogy, with one difference and one deciding reason to prefer the platform word. The difference: on:connect fires on every connect, including when an element is moved and re-inserted, not only the first time. The reason: it is exactly right for SSR. The server renders static markup where nothing is connected to a live document; on the client, hydration adopts the node and connects it, and that is when on:connect fires. "Mount" blurs that boundary; "connect" names precisely the moment an element joins a live, interactive document, which is client-side hydration and never the server.
Timers, subscriptions (SSE/WebSocket), IntersectionObserver, third-party libraries, imperative animation: these are genuinely imperative, with setup and teardown, and have no declarative form. They live in the reserved JavaScript layer (see The JavaScript Layer), at a later Level, where a component may attach an ES-module controller whose connect hook returns a disposer run on disconnect, the shape connectedCallback/disconnectedCallback and React/Svelte effects already established.8 The declarative layer stays free of lifecycle ceremony; the imperative layer is the only part with a real lifecycle, and it is opt-in.
Every expression exposes the paths it reads, and every <param :value> names a subscription, so the graph is known statically. This buys type-checkable expressions, predictable invalidation, ahead-of-time generation for any reactive framework, and a browser runtime that needs no dynamic code.
| Target | Lowers reactivity to |
|---|---|
| React | useState / useMemo / a resource hook |
| Vue | ref / computed / watch |
| Svelte | runes ($state / $derived / $effect) |
| Browser runtime | signals (the TC39 Signals proposal as a candidate substrate)4 |
<state name :value> · <computed name from><set>/bind:; computed is pure and recomputed from dependencies.name, src, type?, debounce?, poll? · <param name :value>.pending, .value, .error, .ok{name} in src is an RFC 6570 path param.name, src, method, enctype? · inputs and <param> as body/query fields.pending, .error, .ok under its name, like a <data>on:success/on:error run handlers for post-write steps.hx-get/hx-trigger/hx-target) and its Locality of Behaviour essay.{name} placeholder syntax).ref/computed, Preact signals). Contrast: RxJS models push streams, not value cells.<data> exposes as .pending/.error/.value/.ok. TanStack Query and Vue Query key a cache by request params, matching requests keyed by resolved params here.connectedCallback/disconnectedCallback, the naming precedent for on:connect/on:disconnect).