Skip to main content
zerotal

References

Quick reference for all Flow JSX props, client expressions, and decorator combinations.

Commands

@zerotal/flow ships one generator:

CommandWhat it does
bun zt make:flow CounterCreate a Flow page or child component (--child, --crud, --layout, --dir)

JSX props reference

Write these as ordinary JSX attributes. The right-hand column shows what each compiles to, for when you're inspecting rendered HTML.

Event binding

You writeBehaviourCompiles to
onClick={this.method}Server action on click (round-trip)flow:click="method"
onClick={() => this.x++}Client expression, no round-tripflow:click="() => $flow.x++"
onSubmit={this.method}Server action on submit (auto-prevents default)flow:submit="method"
onChange={this.method}Server action on changeflow:change="method"
onInput={this.method}Server action on inputflow:input="method"
onKeydown={this.method}Server action on keydownflow:keydown="method"

Two-way binding

You writeBehaviourCompiles to
value={this.x}Two-way bind (@expose) or read-only (@locked)flow:model="x"
checked={this.x}Two-way checkbox bindflow:model="x"
value={this.x} liveSync to server on each keystrokeflow:model.live="x"
value={this.x} blurSync to server on blurflow:model.blur="x"
value={this.form.field}Nested form field bindingflow:model="form.field"

Validation

You writeBehaviourCompiles to
error={this.errors.field}Reactive first validation messageflow:error="field"

Reactive attributes

You writeBehaviourCompiles to
className={cond ? a : b}Reactive class, updates without round-trip:class="…"
class={cond ? a : b}Same as className:class="…"
style={{ color: this.x }}Reactive inline style:style="…"
href={"/posts/" + this.id}Reactive attribute:href="…"
disabled={this.saving}Reactive boolean attribute:disabled="…"

Loading states

Visual loading indicators (showOnLoading, hideOnLoading, loadingClass) wait out a short delay (~200ms) so a fast action never flashes them; loadingAttr is applied immediately (double-click guard).

You writeBehaviourCompiles to
loadingAttr="disabled"Sets disabled immediately while an action is in flightflow:loading.attr.disabled
showOnLoadingShows element while an action is in flight (after the delay)flow:loading
hideOnLoadingHides element while an action is in flight (after the delay)flow:loading.remove
loadingClass="opacity-50"Adds class while loading (after the delay)flow:loading.class.opacity-50
loadingTarget="save"Scope the loading state to specific action(s) (comma-separated)flow:target
loadingTargetExcept="poll"Scope loading to every action except theseflow:target.except
showOnDirtyShows element when local state differs from server snapshotflow:dirty
hideOnDirtyHides element when local state is dirtyflow:dirty.remove
showOnErrorShows element after an action fails (optimistic failed state)flow:failed
hideOnErrorHides element after an action failsflow:failed.remove

Visibility and control

You writeBehaviourCompiles to
show={this.flag}Reactive show/hide off a boolean propflow:show="flag"
confirm="Are you sure?"Browser confirm gate before the action firesflow:confirm
cloakHidden until Alpine initialises (prevents FOUC)x-cloak
You writeBehaviourCompiles to
navigateSPA navigation to href, layout stays mountedflow:navigate
navigate hoverPrefetch page on hover (~60ms debounce)flow:navigate flow:navigate.hover
navigate downPrefetch page on pointer-down (no dwell; dense lists)flow:navigate flow:navigate.down
current={false}Disable automatic data-current attribute
exactdata-current only on an exact URL match (not sub-pages)flow:current.exact

Polling

You writeBehaviourCompiles to
poll={{ every: "5s", action: this.tick }}Call action on an intervalflow:poll
poll={{ every: "30s" }}Re-render on an interval (no specific action)flow:poll

Streaming props

You writeBehaviourCompiles to
stream="ref"Target element for this.stream("ref", content)flow:stream="ref"

Intersection and visibility

You writeBehaviourCompiles to
onIntersect={this.method}Call action when the element enters the viewportflow:intersect

Offline states

The client keeps a live WebSocket; when it drops (and while it reconnects with exponential back-off), Flow flips body[data-flow-connection] to offline and the props below react. Actions taken while offline are queued and replayed in order on reconnect, so the UI keeps working through a blip.

You writeBehaviourCompiles to
showOnOfflineShow the element while the connection is downflow:offline
hideOnOfflineHide the element while the connection is downflow:offline.remove
offlineClass="opacity-50"Add a class while offlineflow:offline.class
offlineAttr="disabled"Set an attribute while offlineflow:offline.attr

Recovering from a drop mid-action

If the connection drops while an action is in flight, the client cannot tell whether it ran. The server is stateless per frame, so a completed action has already committed its database write — while the browser is still holding the state from before it. Guessing either way is wrong: replaying risks doing the work twice, discarding loses it.

So Flow re-derives instead. Any component left mid-action is refreshed from the server on reconnect (onMount() runs again and a fresh patch comes back), which lands on the truth whether the action ran or not. The same applies when an acknowledgement simply times out.

The refresh is announced first, so you can say something rather than have the UI change under the user:

document.addEventListener("flow:desync", (e) => {
  const { components } = (e as CustomEvent<{ components: string[] }>).detail;
  toast(`Reconnected — refreshing ${components.length} component(s).`);
});

Transitions

You writeBehaviourCompiles to
transitionFade/slide the element in when the morph adds it to the DOMflow:transition

Tune the duration with the --flow-transition-duration CSS variable (default 200ms).

Drag-and-drop reordering

Mark a container with onSort (the reorder action) and each child with sortItem (its stable key). Dragging a child calls the action as reorder(key, newIndex) on drop.

You writeBehaviourCompiles to
onSort={this.reorder}Reorder action on the container — (key, index)flow:sort
sortItem="id"Stable key of a draggable childflow:sort:item
sortHandleRestrict the drag grip to this elementflow:sort:handle
sortIgnoreExclude this child from dragging/reorderingflow:sort:ignore
sortGroup="tasks"Allow dragging between containers sharing the group nameflow:sort:group
@expose async reorder(key: string, index: number) {
  const moved = this.items.find((i) => String(i.id) === key);
  if (!moved) return;
  this.items = this.items.filter((i) => i !== moved);
  this.items.splice(index, 0, moved);
  await this.persistOrder();
}

override async render() {
  return (
    <ul onSort={this.reorder}>
      {this.items.map((it) => (
        <li key={String(it.id)} sortItem={String(it.id)}>{it.name}</li>
      ))}
    </ul>
  );
}

A dynamic sortItem={String(it.id)} inside a .map() renders through the standard runtime (not the AOT fast path) — the drag behaviour is identical either way.

The payload does not say which container took the drop. The client reads flow:sort off the container a child was dropped into and calls it (key, index) — so the destination is encoded in which method runs, and nowhere else. For a single sortable list that is invisible. For dragging between containers under one sortGroup it means one action per container:

<ul onSort={this.dropInTodo} sortGroup="tasks">…</ul>
<ul onSort={this.dropInDone} sortGroup="tasks">…</ul>

An arrow (onSort={(k, i) => this.move("todo", k, i)}) cannot stand in, because the attribute's value is used as a method name rather than evaluated. onSort accepts the name as a string, so the handlers can come from a lookup table keyed by column, but they must be declared members.

DOM utilities

You writeBehaviourCompiles to
teleport="body"Move the element to a CSS selector target (modals, tooltips)flow:teleport
ref="name"Name this element as $refs.name for this.$`…` scriptsx-ref

Alpine plugins

You writeBehaviourCompiles to
mask="(999) 999-9999"Format an input as the user typesx-mask
trap="$flow.open"Trap focus while the expression is truthyx-trap
collapseAnimate x-show with a height transitionx-collapse
anchor="$refs.trigger"Float relative to another elementx-anchor

Escape hatch

You writeBehaviour
x-text="$flow.count"Raw Alpine attribute using live client state via $flow
x-show="$flow.open && $flow.count > 0"Raw Alpine expression
flow:click="increment"Hand-written Flow directive (accepted but not needed in JSX)

Client expressions

Inside a client expression — onClick={() => this.X(...)}this. resolves to the live client runtime (no server round-trip to start it). You write the same names as on the server — no $-prefixed syntax, and it all type-checks.

State manipulation

ExpressionBehaviour
this.count++Write to an @expose prop — updates the DOM instantly, then syncs so render() reflects it
this.open = trueSet any @expose prop; @locked props are read-only
this.filter = "active"Works with any primitive value

A write syncs to the server after the expression finishes, unless the same expression also calls a server action (that action's round-trip already carries it) or the value ends back where it started. Client-only UI state belongs in this.store(), which never round-trips.

Querying state

ExpressionBehaviour
this.count > 10Read any @expose or @locked prop
this.user.nameNested property access
this.posts.lengthArray methods and properties
ExpressionBehaviour
this.refresh()Request a fresh server render of this component
this.currentUrl({ query, hash })Build a URL from the current one with merged query params (no navigation) — client-only
this.navigateCurrent({ query, hash })Build that URL and SPA-navigate to it — client-only

currentUrl() / navigateCurrent() merge query params onto the current URL: listed params are added/updated, unlisted ones preserved, and a null/undefined/"" value removes a param. Use currentUrl() in a binding (href={this.currentUrl({ query: { page: this.page + 1 } })}) and navigateCurrent() in a handler (onChange={(e) => this.navigateCurrent({ query: { status: e.target.value || null } })}). Both are client-only — they throw if called from server code. See Query-aware navigation.

Event dispatch

ExpressionBehaviour
this.dispatch("event", data)Dispatch to every component on the page
this.dispatchTo("ComponentName", "event", data)Dispatch only to a specific component class
this.dispatchSelf("event", data)Dispatch only to this component

Parent interaction

ExpressionBehaviour
$flow.parent.method(args)Invoke an action on the nearest ancestor component
$flow.parent.propRead an exposed property of the parent

Global client store

ExpressionBehaviour
$flow.store.ui.darkRead a value from the global client store (reactive)
$flow.store.ui.dark = …Write it — every component reading it re-renders, no round-trip

$flow.store is app-wide UI state that shouldn't round-trip to the server — side-panel visibility, colour scheme, notification drawer, etc. Declare its shape with defineStore(...) at app start and type it by augmenting FlowStore (see The global client store):

<button onClick={() => ($flow.store.ui.sidebarOpen = true)}>Open sidebar</button>
<aside show={$flow.store.ui.sidebarOpen}>Sidebar content</aside>

Decorator quick reference

DecoratorOnEffect
@exposepropertySynced to client; client can mutate via value= or expression
@exposemethodCallable from browser via WebSocket
@lockedpropertySynced to client for display; client cannot change it
@validate((rule) => rule.…)propertyAuto-validated by this.validate(); live on flow:model.live
@urlpropertySynced to URL query string (?prop=value)
@url({ as: "p", history: "push" })propertyCustom param name; push history entry
@sessionpropertyPersisted in HTTP session across page loads
@computedgetterDerived from state; memoized per render pass; not in snapshot
@transientpropertyExcluded from snapshot; reset on every round-trip
@renderlessmethodRuns server-side but skips re-render
@on("event")methodListens for cross-component events (auto-exposed)
@reactivepropertyChild prop the parent can re-push on change
@modelablepropertyTwo-way child prop (parent↔child sync)

Lifecycle hook quick reference

HookGET (initial)WebSocket (subsequent)
onBoot()
onMount()only if this.refresh() called
onHydrate()
onUpdating(prop, val)✓ (per client write)
onUpdated(prop, val)✓ (per client write)
action()
onUpdate()
onRendering()
render()
onRendered(html)
onDehydrate()
onError(error)✓ (on throw)

FlowTest assertion reference

MethodAsserts
t.assertSee(text)Rendered HTML contains text
t.assertDontSee(text)Rendered HTML does NOT contain text
t.assertHasErrors(field)Validation error exists for field
t.assertHasErrors(field, msg)Error for field contains msg
t.assertNoErrors()No validation errors
t.assertRedirectedTo(url)Last action redirected to url
t.assertNotRedirected()Last action did not redirect
t.assertFlashed(level?, msg?)Flash notification was emitted
t.assertDispatched(event)Cross-component event was dispatched
t.page()The component instance
t.html()Rendered HTML string
t.errors()Error bag: Record<string, string[]>
t.effects()Drained effects (flashes, redirects, events)
t.snapshot()Serialised snapshot