Skip to main content
zerotal

Models in Components

A Flow component can hold an ORM model directly. On a route that names one, it arrives loaded:

import { Component, locked } from "@zerotal/flow";
import { Post } from "@app/models/Post.ts";

export class ShowPost extends Component {
  @locked post!: Post; // /posts/:post — the record, already found

  override async render() {
    return <article>{this.post.title}</article>;
  }
}

There is no onMount(), because there is nothing to fetch. The router resolved :post before the component was built, and a field of that type receives the result — so the query, the postId field it would have needed, and the 404 handling all belong to the route rather than to this page. Path parameters covers what a segment binds to and how a model resolves by something other than its primary key.

The other way a model arrives is from a parent that already has it, as a prop:

<PostCard post={this.post} />

Either way the model is a model on the other side — not a plain object shaped like one — and everything below applies the same to both.

Nothing has to be declared. A model travels under its table name — the one @table("…") sets, or the inflected default — which the app already declares and a minifier cannot mangle. On the way back it is found through the registry that app/models discovery populates, which is every model in the app.

Fetching a model in onMount() is the older way of doing this, from before a component could hold one. It still runs, and it is still right for a record no route names and no parent has — a list, a lookup keyed off something the URL does not carry. It is the wrong shape for the record the page is about: that one the route already found.

The decorator is the whole decision: @locked for a model the page displays, @expose for one it edits. Both put the model on the client; only @expose accepts anything back.

What crosses the wire

The snapshot carries the model's id and the result of its toJSON() — the same serialisation your API responses use, honouring visible, hidden and appends.

@table("users")
export class User extends BaseModel {
  static fillable = ["name", "email", "password"];
  static hidden = ["password"];
}

password is never sent. Nothing else has to be configured for that: the model already declares its serialisation surface, and Flow uses it.

hidden is a security control here. On an API response an omitted column is a matter of shape. On a component it is the only thing keeping a value out of the page. A snapshot is signed, not encrypted — the browser can read every byte of it. Declare hidden (or visible) on any model a component holds.

A loaded relation is resolved the same way, through its own toJSON() — every model hides its own columns, however deep it sits.

The id travels separately from the values, in the part of the snapshot a client write cannot reach. Which record a component points at is not something the browser can change — only the values on it, and only the ones below.

What the client may change

Only fillable. Everything else is server-owned, whatever the browser sends:

ColumnSent to the clientClient may write
id — the identityyesno
name — fillable, not hiddenyesyes
role — not fillableyesno
password — fillable and hiddennoyes

A model that declares no fillable is read-only in the browser. That is the ORM's default: it guards mass assignment until told otherwise, and a component does not widen it.

A field outside that set is ignored, not rejected — a crafted payload does not become a server error. To refuse a value loudly instead, or to vet one before it lands, throw from onUpdating().

Editing

Bind to a field of an @exposed model and it is two-way:

export class EditProfile extends Component {
  @expose user!: User;

  @expose async save(): Promise<void> {
    if (this.user.name.trim().length < 2) {
      this.addError("name", "Your name needs at least two characters.");
      return;
    }
    await this.user.save();
    this.flash("Saved");
  }

  override async render() {
    return (
      <form onSubmit={this.save}>
        <input value={this.user.name} blur />
        <span error={this.errors.name} />
        <button>Save</button>
      </form>
    );
  }
}

save() writes only the columns that actually differ. The row was re-read a moment earlier (see Freshness), so the model's idea of "unchanged" is the row as it is now, not the one the page was built from.

A @locked model is display-only, and its fields are not bound: render them as text — <p>{this.post.title}</p> — rather than as an input. An input pointed at one accepts typing and sends nothing.

Validating a model's fields

this.validate() rules are keyed by the component's own exposed properties, and a model's columns are not among them: "user.name" looks for a property with that name, finds nothing, and fails whatever the field actually holds. Two ways round it:

  • Check in the action, then this.addError(field, message) — as above. Right for a field or two.
  • Use a form object for anything larger. It is the shape built for validated multi-field editing, and it can carry values no column has: a confirmation field, a current-password check, an upload.

Hidden fields are writable

hidden governs what is shown, not what may be written. A password is the case that makes the difference: fillable because a user sets it, hidden because the stored hash must never reach the page.

<input type="password" value={this.user.password} blur />

The stored hash is never sent, so the field starts empty. What the user types is held until they save — it travels back to the browser that produced it, and nowhere else. A value the server set is never echoed, and a half-typed one is never written to the durable store.

What reaches the database is a hash rather than what was typed, as long as the column is listed in the model's hashable — see Password hashing.

Relations

A relation that is loaded when the page renders travels with the model, through its own toJSON(). It does not survive the round-trip:

override async onMount(): Promise<void> {
  await this.post.loadMissing(["author"]); // this.post came from the route
}

That renders. The next interaction has no author — the re-read is a find by id, which fetches the row and not the relations that happened to be loaded around it. Reading it then throws the ORM's guard: Relation "author" was accessed on Post without eager loading.

Load what an action needs, where it needs it:

@expose async approve(): Promise<void> {
  await this.post.loadMissing(["author"]);
  this.post.approved = true;
  await this.post.save();
  this.flash(`Approved — ${this.post.author.name} has been credited.`);
}

When the page displays the relation, load it once per round-trip in onHydrate() instead. Rendering is the case worth watching: a template reading this.post.author.name works on the first paint and throws on every interaction after it.

override async onHydrate(): Promise<void> {
  await this.post.loadMissing(["author"]);
}

Freshness

The model is re-read from the database on every round-trip, by id. What the client holds is a rendering of a row, not the row.

Two consequences worth knowing:

  • Server-owned columns are always current. If someone else changes role, the next interaction shows the new value.
  • Unsaved changes survive. A change an action made without calling save() is restored from the snapshot, so a half-filled field does not revert the moment something else happens. Only the writable fields are restored; everything else comes from the fresh row.

If two people edit the same record, the one who saves last wins. Flow does not add optimistic locking — add a version column and check it in save() if a record needs it.

A row that disappears

The re-read is a findOrFail, and it applies the same scopes your own queries do. A record deleted — or soft-deleted — while a page holds it makes the next interaction fail: nothing is patched, and the browser console carries the error. So follow a delete with a navigation rather than leaving the prop pointing at a row that is gone:

@expose async destroy(): Promise<void> {
  await this.post.delete();
  this.redirect("/posts");
}

Collections

An array of models is sent as ids and re-read with a single whereIn query:

@locked posts: Post[] = [];

Collections are read-only on the client. Bind to a single model when you need to edit one.

Two things follow from ids and one whereIn:

  • Order does not travel. The re-read carries no order by, so the order you loaded them in is not guaranteed to come back. Sort where you render, or re-run the real query in onHydrate().
  • Rows that vanish drop out quietly. A whereIn returns what it finds, so a deleted record leaves the array one shorter — unlike a single model, whose disappearance fails the interaction.

What a round-trip costs

Each model prop is one query per interaction, and each collection one more. That is the price of never showing a stale row, and for most pages it is the right trade. When it is not:

  • @transient keeps a value off the client and out of the snapshot entirely — nothing is sent, and nothing is re-read.
  • Hold an id and query it yourself in onHydrate() when the query needs shaping the re-read cannot do: eager loads, ordering, a scope. @locked postId: number plus one query is the whole pattern.
  • A form object when the page is editing many fields — it edits a plain object, and touches the database once, in your action.

Troubleshooting

What you seeWhat it means
No model maps to "posts"The class is not in app/models, or its @table no longer matches.
Post has no table nameGive the class @table("…").
Relation "author" was accessedIt was loaded in onMount(), and this is a later round-trip.
An edited field never savesIt is not in fillable, so the write was ignored.
A field snaps back after an interactionThe same cause, seen from the page: the fresh row won the re-render.
A password field empties itselfThe column is hidden but not fillable, so nothing was applied.

Next steps

  • Decorators@expose, @locked and the rest of the property contract.
  • LifecycleonHydrate(), onUpdating() and where per-round-trip work belongs.
  • Forms & Uploads — form objects, which are the right shape for multi-field editing and for anything a model cannot carry.
  • Mass assignmentfillable, guarded, hidden and visible, which this page builds on.
  • Relationships — eager loading, load() and loadMissing().