This is part 7, and it's the one where everything connects. Part 6 gave the frontend's shape — two apps on one foundation, with components that render themselves from typed metadata. This part builds an actual screen, and the punchline is that there's still nothing new: the hooks you reach for are the read model from part 3 and the write model from part 5, wrapped just enough for a component to use. Learn how one screen is wired and you've learned them all.
A screen is a handful of hooks
Picture an ordinary operations screen: a header with a count badge, a filter, a table of objects, a detail card when you click a row, and a button that does something. It feels like a lot. It isn't — each region is one hook, and each hook is one of the operations you already know.
The hooks line up one-for-one with the model you already have:
useObjects(set)— a page of an object set. This is part 3'sfetchPage.useObject(type, pk)— one object by key. Part 3's fetch-by-id.useLinks(object, link)— the linked objects. Part 3's traversal.useCount(set)anduseAggregation(set, spec)— Part 3's count and aggregate.useAction(name)— invoke an action. This is part 5's write lifecycle.
👩💻 Mai (junior): So the hooks aren't really new concepts?
👨🏫 Hamed (mentor): Not one. They're the same operations from Parts 3 and 5, wrapped in the things a component needs that a raw call doesn't — a loading state, an error state, caching so two components asking the same question don't fetch twice, and a refetch when the data changes. The data ideas are all behind you. This part is just the thin adapter between them and the screen.
Reading: useObjects is fetchPage with a loading state
Here's the list. You build an object set exactly as in Part 3, hand it to useObjects, and get back the page plus the states a UI needs:
function AssetList() {
// an object set — straight from part 3
const criticalActive = client.objects(Asset)
.where({ status: { eq: "active" }, criticality: { gte: 4 } })
.orderBy("criticality", "desc");
const { data, isLoading, error, fetchMore } = useObjects(criticalActive, { pageSize: 25 });
if (isLoading) return <Spinner />;
if (error) return <ErrorBanner error={error} />;
return <ObjectTable rows={data} onEndReached={fetchMore} />;
}
Two things to notice. The query is just a Part 3 object set — useObjects adds nothing to what you're asking, only the bookkeeping around asking it. And the result goes into ObjectTable, the metadata-driven kit component from part 6: it reads each column's type to format cells — dates formatted, enums as chips, booleans as yes/no — so you write zero per-column code. The whole read path is object set → useObjects → ObjectTable, and every piece in that chain you've already met.
Writing: useAction is the action lifecycle, optimism included
Now the button. useAction wraps Part 5's entire write story — including the optimistic update and rollback you watched in the playground — behind a run() call:
function AcknowledgeButton({ alert }) {
const ack = useAction("acknowledgeAlert");
return (
<button disabled={ack.isPending}
onClick={() => ack.run({ alert: alert.id, assignee: currentUser.id })}>
{ack.isPending ? "Acknowledging…" : "Acknowledge"}
</button>
);
}
The component does almost nothing — it calls run(). Underneath, the hook performs the lifecycle from Part 5: it applies the edit optimistically so the UI flips at once, sends the action to the server to be validated and checked, and then either commits it (with the audit entry) or rolls back and surfaces the error. The component never spells any of that out; it lives in the hook, once, for every action.
Where reads and writes meet
There's one more move the write hook makes, and it's the quiet hinge of the whole frontend. After an action commits, the object sets that touched those objects are invalidated — so every useObjects on screen showing the changed data refetches. Acknowledge an alert and the table updates, the count badge in the header drops by one, and a detail card open elsewhere refreshes — all without wiring those screens together.
👩💻 Mai: So I never manually update the table after the button runs?
👨🏫 Hamed: Right — and you shouldn't. The action is the source of truth; the lists just watch the data and re-read when it changes. If you hand-patched the table yourself, you'd have two copies of the truth drifting apart. Let the write commit and let the reads refetch. They meet at invalidation, and the screen stays honest.
The kit you arrange
Building a screen, then, is mostly arranging a few kit components and pointing them at object sets and actions. You met two in Part 6; the full handful:
AutoField— a form input chosen from a property's type.ObjectTable— a table whose cells format by type.ObjectCard— a detail view of one object.LinkField— renders a link by its cardinality (from part 4): a to-one link becomes a single picker, a to-many becomes a multi-select or an embedded list.
Every one of these is already type-aware and security-aware, so you rarely drop down to raw inputs. A screen is these pieces, composed, over the hooks above.
Two ways to assemble it: bespoke or config-driven
Here's the last open choice, and it's about who arranges the pieces.
Bespoke is what you've seen — a developer writes the screen in code, like the AssetList above. Total control over layout, branding, and flow. This is the right call for the polished, customer-facing surface where the experience matters.
Config-driven flips it: a generic renderer reads a view-spec — data that says which kit components go where, bound to which object sets and actions — and renders the screen with no per-screen code.
{
"screen": "Assets",
"blocks": [
{ "kind": "table", "objectSet": { "base": "Asset", "where": { "status": "active" } },
"columns": ["name", "status", "criticality"] },
{ "kind": "action", "action": "acknowledgeAlert", "label": "Acknowledge" }
]
}
That view-spec is exactly what the Builder's app-editor from Part 6 produces — which is why the Builder's preview can be the very same renderer the end user gets, just pointed at an in-progress spec. The two approaches aren't rivals: they consume the same hooks and the same kit. One reads its layout from a file; the other has it written in JSX.
👩💻 Mai: So config-driven is just bespoke where the arrangement comes from data instead of code?
👨🏫 Hamed: That's the cleanest way to see it. Same foundation, two front doors. In practice you mix them — config-driven for the internal operational dashboards people assemble themselves, bespoke for the customer-facing product that needs to look and feel deliberate. Because both stand on the one foundation, it's never an either/or you're locked into.
The one-paragraph summary
A screen is assembled from hooks, and each hook is a read or write you already know, wrapped for a component's needs — loading, error, cache, refetch. useObjects is part 3's fetchPage; useAction is part 5's optimistic action. Reads flow object set → useObjects → ObjectTable, which formats cells by type; writes flow button → useAction → optimistic apply → commit-or-rollback, after which the on-screen sets invalidate and refetch — so reads and writes meet at invalidation and the screen never drifts. You compose from a small, type- and security-aware kit (AutoField, ObjectTable, ObjectCard, LinkField), and you do that composing either in code (bespoke) or from a view-spec (config-driven) — the same foundation behind both doors. That's the entire frontend: not a new system, just the read and write models, rendered.
That closes the core of the series — the model, how you read it, its building blocks, how you change it, and how you build on it: part 1 · part 2 · part 3 · part 4 · part 5 · part 6. One optional epilogue remains for the curious — how the model itself evolves over time: versioning, branching, and migrations without breaking the apps standing on it.