Page variable types
A custom page declares the data it works with as typed variables. Each one has a name and a type, and that type is what makes everything downstream — bindings, placeholders, form fields — behave correctly.
You won't write much markup before you need a place to put data: the customer's name as they type it, a running total, a flag for whether a modal is open. Every one of those is a declared variable. Here are the kinds you'll reach for:
- Object variables — hold a whole record, bound to an object's API.
Shape:
{name:'invoice', type:{name:'object'}, typeDefinition:{api:'invoice__m'}}. - Wrapper variables — a custom shape you define (a bag of named elements). This is the heart of Unit 2.
- Scalars — a single value:
text,number,boolean,date,currency. - List-type variables — a collection (rows for a table, options for a repeater).
- UI-state flags — booleans that drive the screen, not the data:
{name:'isSubmitting', type:{name:'Boolean'}, default:'false'}.
The reason types matter is one helper: ctx.bind. When you bind a form input to a
variable, ctx.bind auto-resolves the input's type from the variable's
declared type. Declare a field as currency and the input shows its symbol; declare it
as a date and it parses dates. You almost never pass an explicit {type:...}.
Every ctx.bind path must point at a declared variable (or a
wrapper element, or a record.* field). You can not bind to a value you
only made in JavaScript — e.g. ctx.set('x', 0) with no matching variable. That's
blocked at save. If you want to bind it, declare it first.
The object pattern
Here's the one rule that shapes every page you'll ever build: group related form fields into a single wrapper instead of scattering them across many loose scalars.
Say your page collects a customer's name, email, phone, amount and a note. The tempting way is five separate variables. Don't. Compare:
// five unrelated variables, no shared shape
{name:'customerName', type:{name:'text'}}
{name:'customerEmail', type:{name:'text'}}
{name:'customerPhone', type:{name:'text'}}
{name:'amount', type:{name:'number'}}
{name:'note', type:{name:'text'}}// 1. define the wrapper shape "formData" with its elements
formData.elements = [
{name:'customerName', type:{name:'text'}},
{name:'customerEmail', type:{name:'text'}},
{name:'amount', type:{name:'number'}}
]
// 2. declare ONE variable that references that wrapper
{name:'formData', type:{name:'Wrapper'}, typeDefinition:{name:'formData'}}The payoff is everywhere. Your bindings become a clean dotted path —
ctx.bind('#name', 'formData.customerName') — and when you hand the data to a
function, you send one object param instead of N scalars:
// before: five params to keep in sync
[{name:'customerName', value:'customerName'},
{name:'customerEmail', value:'customerEmail'}, /* …and on */]
// after: one tidy object
[{name:'formData', value:'formData'}]This isn't a style suggestion. At save, the platform auto-consolidates loose scalars
into a formData wrapper and rewrites your binding paths to match. If you
build the messy way, the platform fixes it for you — but you'll be debugging paths that suddenly
read formData.customerName. Build with the wrapper from the first keystroke and
there's nothing to untangle.
For developers — what stays standalone
Consolidation is smart, not blunt. It groups the loose form-input scalars and leaves
everything else alone. These variables are never pulled into
formData:
- UI-state booleans —
isLoading,isSubmitting,showModal,canEdit,hasError. - Input-connected variables — anything already wired to a live input.
- Iterator variables — a loop's current item.
- Already-structured variables — object, wrapper and lookup types.
- List-type variables — collections stay collections.
So your isSubmitting flag and your invoice object survive untouched;
it's only the flat name/email/amount scalars that get folded together.
record vs pageData
A page sees two data sources at once: the current record it's
attached to, and its own pageData — the variables you just declared.
Knowing which is which keeps you out of trouble:
record.*— the current record's fields. Read-only, resolved from the object's schema at runtime. Use it to display what's already saved:record.accountName,record.total.pageData— the page's own declared variables. These override record fields of the same name. This is your editable working copy.
Two helpers move data in and out of pageData: ctx.get reads it and
ctx.set writes it. Placeholders and bindings merge both sources — and when
a name exists in both, pageData wins.
// read a wrapper element out of pageData
const current = ctx.get('formData.amount');
// write a value back into pageData
ctx.set('formData.amount', 100);
// a placeholder in the page's HTML, resolved at render
<span>{|formData.amount|}</span>Bind editable fields to your own variables (formData.*) — those changes
live in pageData and are yours to send to a function. When you only need to
show a value that's already saved, read it straight off the record with
record.*. Editing goes to pageData; display comes from the record.
Matching field types
A form field's binding must match the object field's type, or it simply won't behave — a date won't parse, a picklist won't list, a lookup won't save.
The good news from Unit 1 carries through: because ctx.bind infers the type from
the declared variable, you rarely pass {type:...} by hand. You just declare
the variable (or wrapper element) with the right type and let the binder do the matching:
lookup field → binds as a lookup // stores {_id, name}
picklist field → binds as a picklist // carries its options
currency field → binds as currency // auto-shows the symbol
date field → binds as a date // parses Mongo date envelopes
text field → binds as text // the plain defaultThe mistake to avoid is binding everything as plain text because it's the default. A lookup is not text. A picklist is not text. Declare each wrapper element with the type that mirrors its object field, and the binding carries the right shape.
Bind a lookup field as text and it loses its {_id, name} shape —
the page shows a string, but the save has no id to write, so it fails. The fix is never to patch
the binding call; it's to declare the variable's type correctly (lookup,
picklist, currency, date) and let ctx.bind resolve the rest.
You can declare a page's data, group it the right way with the object pattern, tell
record.* from pageData, and match every binding to its field type.
That's the foundation every page stands on. Pass the checkpoint to claim your
Data Binder stamp — then keep going with Trail 03,
Master rtext, where the markup, components and scripts come
together into a full page.
Earn the Data Binder stamp
Four questions to complete the Build Pages trail.