Skip to main content
zerotal

Documentation


Documentation / @zerotal/flow / index / Component

Abstract Class: Component

Defined in: flow/src/Component.ts:339

Base class for Flow pages — reactive server-side components rendered over a WebSocket.

Remarks

A Flow component's state lives on the server. You subclass Component, hold state in fields, and implement render to return an HtmlNode (authored in .tsx with jsxImportSource: "@zerotal/flow"). The root element must carry data-flow-root.

Only fields and methods the component opts into are reachable from the browser:

  • @expose marks a field as reactive (streamed to the client, writable via flow:model / $flow.$set) or a method as a callable action (invoked over the socket, e.g. flow:click).
  • @locked sends a field to the client read-only — it round-trips in the snapshot but the client cannot mutate it (enforced in $set).

Between requests, the component is serialised into an HMAC-signed snapshot and rehydrated on the next round-trip, so tampering with client-held state is rejected. When an action runs, the server re-renders and streams a DOM diff that the browser applies via Alpine's morph — only the changed HTML crosses the wire.

Lifecycle hooks fire in order around each request: onBoot → (onMount on the initial GET / onHydrate on round-trips) → property updates → the action → onUpdateonRenderingrenderonRenderedonDehydrate. Actions may emit side effects — flashes, redirects, events, downloads, client scripts — which are drained after the action and folded into the response frame.

Examples

A counter with an exposed field and an action, bound reactively:

import { Component, expose } from "@zerotal/flow";

export class Counter extends Component {
  @expose count = 0;

  @expose increment() {
    this.count++;
  }

  override async render() {
    return (
      <div data-flow-root>
        <p>{this.count}</p>
        <button flow:click="increment">+</button>
      </div>
    );
  }
}

A form-style component with validation, a flash, and a redirect from an action:

import { Component, expose, locked } from "@zerotal/flow";

export class ProfilePage extends Component {
  @locked userId = 0;
  @expose name = "";

  @expose async save() {
    await this.validate({ name: (rule) => rule.required().min(2) });
    await User.update(this.userId, { name: this.name });
    return this.redirect("/dashboard").withSuccess("Profile saved.");
  }

  override async render() {
    return (
      <div data-flow-root>
        <input {...this.bind("name")} />
        {this.errors.has("name") && <span>{this.errors.name}</span>}
        <button flow:click="save">Save</button>
      </div>
    );
  }
}

Extended by

Constructors

Constructor

new Component(): Component

Returns

Component

Actions

signal

Get Signature

get signal(): AbortSignal

Defined in: flow/src/Component.ts:447

The AbortSignal for the running @task. Pass it to fetch/an SDK, or check this.signal.aborted in a loop, for cooperative cancellation. Outside a task it is an already-inert signal that never aborts, so @task code reads the same regardless.

Returns

AbortSignal


cancelled

Get Signature

get cancelled(): boolean

Defined in: flow/src/Component.ts:455

True when the running @task has been cancelled from the client.

Returns

boolean


flash()

flash(message, optionsOrLevel?): FlashBuilder

Defined in: flow/src/Component.ts:710

Emit a flash notification to the client.

Returns a FlashBuilder so the toast can be configured fluently. The second argument may also be a level string (back-compat) or a FlashOptions object (for dynamic config):

Parameters

message

string

the toast text.

optionsOrLevel?

"error" | "success" | "warning" | "info" | FlashOptions

a FlashLevel shorthand or a FlashOptions object.

Returns

FlashBuilder

a FlashBuilder for fluent configuration.

Example

this.flash("Saved");                              // success toast
this.flash("Bad input", "error");                 // level shorthand
this.flash("Saved", { type: "success", duration }); // options object
this.flash("Post deleted").warning().duration(8000).progressBar();

refresh()

refresh(): Promise<void>

Defined in: flow/src/Component.ts:859

Force onMount() to run again on the CURRENT WebSocket round trip. Use this to reload external data without requiring a full page reload.

Returns

Promise<void>


client()

client(script): void

Defined in: flow/src/Component.ts:879

Queue a raw JavaScript expression to be evaluated in the browser after the current action's DOM patch is applied.

The expression runs in the Alpine context of the component root element: $el, $refs, and other Alpine magic properties are all available.

Multiple calls are batched and executed in order after the single round trip.

Parameters

script

string

Returns

void

Example

this.client(`$refs.titleInput.focus()`);
this.client(`$el.querySelector('.toast').classList.add('visible')`);

Security

Never interpolate unescaped user input into the expression string.


title()

Call Signature

title(): string

Defined in: flow/src/Component.ts:887

Gets the current title value.

Returns

string

Call Signature

title(value): void

Defined in: flow/src/Component.ts:897

Update document.title in the browser after this action completes.

Parameters
value

string

Returns

void

Example
this.title(`Search: ${this.query}`);

download()

download(filename, content, mime?): void

Defined in: flow/src/Component.ts:1170

Trigger a browser file download.

content is either text (CSV, JSON, an SVG) or the raw bytes of a binary file. Text is encoded as UTF-8; bytes are sent exactly as given, which is what a format like a spreadsheet or a PDF requires — passing those through a string would re-encode every byte above 127 and corrupt the file.

Parameters

filename

string

the download's suggested file name.

content

string | Uint8Array<ArrayBufferLike>

text, or the file's raw bytes (base64-encoded to travel).

mime?

string = "application/octet-stream"

the MIME type. Defaults to application/octet-stream.

Returns

void

Example

async export() {
  const csv = this.buildCsv(this.items);
  this.download('export.csv', csv, 'text/csv;charset=utf-8');
}

stream()

stream(ref, content, opts?): void

Defined in: flow/src/Component.ts:1396

Push content to the client MID-ACTION — before the final patch — into any element marked flow:stream="ref". Appends by default; pass { replace: true } to overwrite instead.

Works only during WebSocket actions (no-op during SSR).

Parameters

ref

string

the flow:stream="ref" target element.

content

string

raw HTML appended (or replacing) inside the target.

opts?

pass { replace: true } to overwrite instead of append.

replace?

boolean

Returns

void

Security

content is injected as raw innerHTML on the client. Never pass unescaped user input directly — sanitize or escape it first, or use a trusted HTML sanitizer (e.g. DOMPurify) client-side.

Example


Expose

async generate() { for await (const token of llm.stream(this.prompt)) { this.stream('answer', token); } }

Errors

onError()

onError(error): Promise<void>

Defined in: flow/src/Component.ts:591

Called when an action throws an unhandled error. Flow catches the error and still re-renders; override to customise (log, swallow, re-flash). The default flashes the message. (Livewire exception().)

Parameters

error

Error

the error thrown by the action.

Returns

Promise<void>

Events

dispatch()

dispatch<K>(name, ...args): void

Defined in: flow/src/Component.ts:1057

Dispatch a named event to all other Flow components on the page. Components with a matching @on('event-name') decorator will have their listener method called automatically.

Type Parameters

K

K extends string

Parameters

name

K

args

...K extends never ? EventArgs<FlowEvents[K]> : [Record<string, unknown>]

Returns

void

Example

async save() {
  const post = await Post.create(this.form);
  this.dispatch('post-created', { id: post.id });
}

dispatchTo()

dispatchTo<K>(component, name, ...args): void

Defined in: flow/src/Component.ts:1075

Dispatch an event ONLY to components of a given class name (skips all others listening for the same event). Mirrors Livewire's dispatch()->to(...).

Type Parameters

K

K extends string

Parameters

component

string

name

K

args

...K extends never ? EventArgs<FlowEvents[K]> : [Record<string, unknown>]

Returns

void

Example

this.dispatchTo('Dashboard', 'post-created', { id: post.id });

dispatchSelf()

dispatchSelf<K>(name, ...args): void

Defined in: flow/src/Component.ts:1094

Dispatch an event only to THIS component (it won't bubble to others). Mirrors Livewire's dispatch()->self().

Type Parameters

K

K extends string

Parameters

name

K

args

...K extends never ? EventArgs<FlowEvents[K]> : [Record<string, unknown>]

Returns

void

Example

this.dispatchSelf('refresh');

Lifecycle

onMount()

onMount(_ctx?): Promise<void>

Defined in: flow/src/Component.ts:502

Called ONCE on the initial HTTP GET request. Never called on subsequent WebSocket round trips (call this.refresh() to force it).

Receives the route HttpContext — the same argument controllers get — so dynamic-segment pages can read implicitly-bound models off ctx.params:

override async onMount(ctx: HttpContext) {
  this.accountId = (ctx.params.account as Account).id;
}

Bound models persist across WebSocket round-trips via Flow synths (store the model on a @locked/@expose field), so they need not be re-resolved on every action.

Parameters

_ctx?

HttpContext<Record<string, string>>

the route HttpContext (initial GET only).

Returns

Promise<void>


onUpdate()

onUpdate(): Promise<void>

Defined in: flow/src/Component.ts:510

Called once after the invoked action method completes, just before re-rendering. Distinct from onUpdated(), which fires per client-driven property change.

Returns

Promise<void>


onBoot()

onBoot(_ctx?): Promise<void>

Defined in: flow/src/Component.ts:523

Called at the BEGINNING of every request for this component — both the initial HTTP render and every subsequent WebSocket round-trip — after state is restored (round-trips) or @url/@session seeded (initial), but before onMount() / onHydrate(), property updates, and the action. Use for setup that must run on every request. (Livewire boot().) Receives the route HttpContext — note that on WebSocket round-trips the URL is the stored route pattern, so raw ctx.params are only populated on the initial GET; prefer onMount() for reading bound models.

Parameters

_ctx?

HttpContext<Record<string, string>>

Returns

Promise<void>


onHydrate()

onHydrate(): Promise<void>

Defined in: flow/src/Component.ts:533

Called at the beginning of every SUBSEQUENT (WebSocket) request, right after the component is rebuilt from its snapshot — never on the initial render (use onMount() for that). Ideal for re-deriving non-persisted/protected state from restored properties. (Livewire hydrate().)

Returns

Promise<void>


onDehydrate()

onDehydrate(): Promise<void>

Defined in: flow/src/Component.ts:542

Called at the END of every request, just before the component is serialised into its snapshot. Use to normalise state back into a serialisable shape. (Livewire dehydrate().)

Returns

Promise<void>


onUpdating()

onUpdating(_prop, _value, _key?): Promise<void>

Defined in: flow/src/Component.ts:556

Called BEFORE a client-driven property update is applied (a value/checked input or $flow.$set). Throw to reject the update. Only fires for writable (@expose) properties. For a single property, define onUpdating<Prop>(value, key?) — e.g. onUpdatingEmail(). (Livewire updating().)

Parameters

_prop

string

_value

unknown

_key?

string

Returns

Promise<void>


onUpdated()

onUpdated(_prop, _value, _key?): Promise<void>

Defined in: flow/src/Component.ts:566

Called AFTER a client-driven property update is applied. For a single property, define onUpdated<Prop>(value, key?) — e.g. onUpdatedUsername() to normalise a value such as lower-casing. (Livewire updated().)

Parameters

_prop

string

_value

unknown

_key?

string

Returns

Promise<void>


onRendering()

onRendering(): Promise<void>

Defined in: flow/src/Component.ts:573

Called BEFORE render() runs. (Livewire rendering().)

Returns

Promise<void>


onRendered()

onRendered(_html): Promise<void>

Defined in: flow/src/Component.ts:581

Called AFTER render() produces this component's HTML. (Livewire rendered().)

Parameters

_html

string

Returns

Promise<void>

redirect()

redirect(url, status?): RedirectFlash

Defined in: flow/src/Component.ts:781

Redirect the client to url after the current action completes.

Returns a builder so a flash can be chained (mirrors the HTTP ResponseBuilder):

Parameters

url

string

Destination URL.

status?

number

Optional HTTP-style status (advisory — the client navigates regardless).

Returns

RedirectFlash

a RedirectFlash builder for chaining a flash onto the redirect.

Example

this.redirect("/dashboard");
return this.redirect(next || "/", 303).withSuccess("Welcome back.");
return this.redirect("/login").withError("Please sign in.");

redirectRoute()

redirectRoute(name, params?, status?): RedirectFlash

Defined in: flow/src/Component.ts:820

Redirect to a named route, mirroring Livewire's redirectRoute('profile', ['id' => 1]). Route params fill :segment placeholders; any extra keys become query-string params.

Parameters

name

string

The route name registered with .name(...).

params?

Record<string, string | number> = {}

Route + query params.

status?

number

Optional HTTP-style status (advisory — the client navigates regardless).

Returns

RedirectFlash

a RedirectFlash builder for chaining a flash onto the redirect.

Example

return this.redirectRoute("profile", { id: 1 }).withSuccess("Saved.");
this.redirectRoute("posts.show", { slug: "hello", ref: "email" }); // /posts/hello?ref=email

redirectIntended()

redirectIntended(fallback?, status?): RedirectFlash

Defined in: flow/src/Component.ts:842

Redirect the user back to where they were headed before being intercepted — Livewire's redirectIntended('/default/url'). Reads (and clears) the intended_url stored in the session by AuthMiddleware, falling back to fallback when none is stored or the stored URL is cross-origin (open-redirect guard).

Parameters

fallback?

string = "/"

URL to use when no intended URL is available. Defaults to /.

status?

number

Optional HTTP-style status (advisory — the client navigates regardless).

Returns

RedirectFlash

a RedirectFlash builder for chaining a flash onto the redirect.

Example

if (await Auth.attempt(creds)) return this.redirectIntended("/dashboard");

currentUrl()

currentUrl(_options?): string

Defined in: flow/src/Component.ts:1133

Build a URL from the current one with merged query params — client-only. Use it in a JSX expression: href={this.currentUrl({ query: this.q })}, class={this.currentUrl() === "/" ? "on" : ""}, or {this.currentUrl(...)}. The compiler rewrites this.currentUrl$flow.currentUrl so it runs on the client. It never runs on the server — hitting this throw means the call reached server code (e.g. a page the compiler couldn't statically compile, or a server action).

Parameters

_options?

CurrentUrlOptions

Returns

string

Throws

when executed on the server (it is meant to be compiled to $flow.currentUrl).


navigateCurrent(_options?): Promise<void>

Defined in: flow/src/Component.ts:1145

Build the URL as currentUrl does, then SPA-navigate to it — client-only. Use it in a client handler: onClick={() => this.navigateCurrent({ query: { page: 2 } })}. The compiler rewrites it to $flow.navigateCurrent. To navigate from server code, return a redirect().

Parameters

_options?

CurrentUrlOptions

Returns

Promise<void>

Throws

when executed on the server (it is meant to be compiled to $flow.navigateCurrent).

Rendering

render()

abstract render(): Promise<HtmlNode>

Defined in: flow/src/Component.ts:604

Return the JSX that represents this page. Must include a single root element with data-flow-root.

Returns

Promise<HtmlNode>

the rendered HtmlNode tree for this component.


placeholder()

placeholder(): HtmlNode

Defined in: flow/src/Component.ts:617

Return the placeholder HTML shown while a lazy/deferred component loads. Override this in child components that use { lazy: true } or { defer: true }.

Returns

HtmlNode

Example

override placeholder() {
  return <div class="skeleton animate-pulse h-24 w-full rounded-lg" />;
}

layout()

layout(page): HtmlNode | Promise<HtmlNode>

Defined in: flow/src/Component.ts:647

Wrap the rendered page root in a layout shell. page is the already-rendered <div data-flow-root>…</div> node; return whatever JSX wraps it. The default is no layout (returns page unchanged).

This is the JSX-native alternative to static layout = SomeLayout: a layout is just a component you wrap the page in, and its regions are ordinary props — the same Page.layout = (page) => <AppLayout>{page}</AppLayout> convention the framework's React/Inertia pages already use. static layout still works; when both are present, this method wins.

The shell renders once (on the initial GET) and stays outside the reactive root, so it is not re-rendered or re-sent on WebSocket actions and survives SPA navigation between pages that wrap with the same layout.

Parameters

page

HtmlNode

the already-rendered <div data-flow-root> page node to wrap.

Returns

HtmlNode | Promise<HtmlNode>

the wrapping shell (or page unchanged for no layout).

Example

override layout(page: HtmlNode) {
  return <AppLayout title={ProfilePage.title} actions={<Save />}>{page}</AppLayout>;
}

slot()

slot(name?): HtmlNode

Defined in: flow/src/Component.ts:677

Return the HTML the parent passed for a named slot, for placing inside this component's render(). Call with no argument for the default slot (the child's plain children), or a name for a named slot (slots={{ header: … }} on the parent). Returns an empty node when the slot wasn't provided, so {this.slot("footer")} is safe to leave in the template unconditionally.

Slot content is rendered in the PARENT's scope and carried in this component's snapshot, so it survives the child's own round-trips. Use hasSlot() to branch on whether a slot was supplied (e.g. to omit a wrapping <header> entirely).

Parameters

name?

string = "default"

the slot name; omit for the default slot.

Returns

HtmlNode

the slot's HTML, or an empty node when the slot was not provided.

Example

override async render() {
  return (
    <div class="card">
      {this.hasSlot("header") && <header>{this.slot("header")}</header>}
      <div class="body">{this.slot()}</div>
      {this.hasSlot("footer") && <footer>{this.slot("footer")}</footer>}
    </div>
  );
}

hasSlot()

hasSlot(name?): boolean

Defined in: flow/src/Component.ts:685

True when the parent supplied (non-empty) content for the given slot.

Parameters

name?

string = "default"

Returns

boolean


child()

child<C>(ChildClass, opts?): Promise<HtmlNode>

Defined in: flow/src/Component.ts:1233

Embed another Component as a nested component with its own isolated state, its own WebSocket update cycle, and its own snapshot.

Children are islands: a parent re-render does NOT re-render existing children (their DOM and state are preserved on the client), and a child update never touches the parent. Use key when embedding the same component class multiple times (e.g. in a loop).

props are assigned to the child instance before onMount() runs.

Pass lazy: true to use IntersectionObserver — onMount() is deferred until the placeholder enters the viewport. Pass defer: true to load immediately after page paint (no intersection check).

Type Parameters

C

C extends Component

Parameters

ChildClass

() => C

the child Component class to embed.

opts?

key disambiguates repeated instances; props seed the child before onMount(); lazy/defer render a placeholder first; slots pass named slot HTML.

key?

string | number

props?

Partial<C>

lazy?

boolean

defer?

boolean

slots?

Record<string, string>

Named slot HTML (name → html, default slot keyed "default"), rendered by the parent.

Returns

Promise<HtmlNode>

the child's rendered HtmlNode (root or placeholder).

Example

override async render() {
  return <div>
    <h1>Dashboard</h1>
    <StatsWidget />
    <CounterWidget key="a" step={5} />
    <SlowWidget lazy />
  </div>;
}

State & exposure

durable?

static optional durable?: boolean | { ttl?: string; scope?: "user" | "session"; }

Defined in: flow/src/Component.ts:467

Opt into durable/resumable snapshots: static durable = true (or { ttl, scope }). The signed snapshot is persisted server-side after every request, keyed by user (or session) + route, and restored on a fresh GET so the user resumes exactly. See clearDurable to drop the stored state on flow completion.


clearDurable()

clearDurable(): void

Defined in: flow/src/Component.ts:479

Forget this component's durable snapshot at the end of the current request — call it when a durable flow completes (a wizard finished, a form submitted) so the next visit starts fresh instead of resuming the finished state. No-op unless the component opted into static durable.

Returns

void


bind()

bind(key): Record<string, unknown>

Defined in: flow/src/Component.ts:1192

Emit the HTML attributes needed for flow:model two-way binding.

Parameters

key

string

the @expose property to two-way bind.

Returns

Record<string, unknown>

the flow:model + value attributes to spread onto an input.

Example

<input {...this.bind('name')} />
// → <input flow:model="name" value="Alice" />

Validation

errors

Get Signature

get errors(): ErrorsProxy

Defined in: flow/src/Component.ts:384

Typed access to this component's validation error bag.

  • this.errors.has("field")boolean
  • this.errors.field → an ErrorField for error={this.errors.field}
  • this.errors.add("field", "message") → add an error manually
  • this.errors.add({ field: "message" }) → add several at once
  • this.errors.clear(field?) → clear one field, or all
Returns

ErrorsProxy


validate()

validate(rulesOrForm?): Promise<void>

Defined in: flow/src/Component.ts:937

Validate page properties against rules. If validation fails, errors are stored and a ValidationError is thrown (caught by the framework, which re-renders the page with $errors populated client-side).

Pass explicit rules or omit to use @validate decorator rules.

Error persistence: validation errors survive across actions and are re-sent on every patch until the next validate() call clears them (on success) or resetValidation() is called explicitly. If you want errors cleared at the start of every action, call this.resetValidation() before the new validate() call.

Parameters

rulesOrForm?

ValidationRules | { __isFlowForm: true; _validateToErrors: Record<string, string[]>; }

explicit per-field rules, a Form instance to validate, or omit to use the component's @validate decorator rules.

Returns

Promise<void>

Example

async save() {
  this.validate({
    email:    (rule) => rule.required().email(),
    password: (rule) => rule.required().min(8),
  });
  // reaches here only if valid
}

Throws

when validation fails — the framework catches it and re-renders with this.errors populated.


addError()

addError(field, message): void

Defined in: flow/src/Component.ts:1020

Manually add a validation error for a field.

Parameters

field

string

message

string

Returns

void

Example

this.addError('email', 'That email is already taken.');

resetValidation()

resetValidation(field?): void

Defined in: flow/src/Component.ts:1034

Clear validation errors — all fields, or just the specified field.

Parameters

field?

string

Returns

void

Example

this.resetValidation();          // clear all
this.resetValidation('email');   // clear email only