Aquargin Way / Master rtext / Module R3

Binding Fields & Buttons

Your page has markup and data — now connect them. ctx.bind is the one call that ties a DOM element to a data path: it reads, writes, listens and re-syncs, so a field and its value move together without a line of glue code.

5 units ~20 min Earn the Binder stamp
Unit 1 of 5

One bind, both directions

ctx.bind wires an element to a data path — read, write, event and re-sync, all in one line.

When you call it, four things happen at once. It reads the current value out of pageData and paints it into the element. It attaches the right input / change listener so anything the user types flows back. It writes that edited value into pageData. And it re-syncs the element whenever that path changes from elsewhere (a function response, another bind, code that calls ctx.set). One call, both directions, kept in step.

The signature is small, and the third argument is optional:

signature
ctx.bind(selector, path, options?)

// selector — a CSS selector for ONE element on the page
// path     — a dotted path into pageData (record.* is allowed)
// options  — optional; type is auto-resolved from the variable

Because the type is auto-resolved from the declared variable, you usually pass no type at all. Two text fields, done:

text binds
ctx.bind('#name', 'record.name', { required: true });
ctx.bind('#email', 'record.email');

Compare that to wiring the same field by hand — read it in, add a listener, push the value back, and you still wouldn't get external re-sync:

the anti-pattern
// three lines, and no re-sync — don't do this
const el = document.querySelector('#name');
el.value = ctx.get('record.name');
el.addEventListener('input', e => ctx.set('record.name', e.target.value));

One ctx.bind replaces all of that — and adds the syncing the manual version forgot.

Bind static elements only

ctx.bind finds its element once, at bind time. Only use it on elements that are static — outside any {|#each|} loop or {|#if|} block. For elements that live inside a loop (and get re-rendered), you use a delegated handler instead — that's Unit 5.

Unit 2 of 5

Every field type

Text, number, currency, phone, date, boolean, picklist, lookup — each is one ctx.bind. The options just tell the binding what kind of input to manage.

  1. Text & number

    Plain text needs nothing; a number field declares type:'number' so the value is coerced to a number, not a string.

    text · number
    ctx.bind('#name', 'record.name', { required: true });
    ctx.bind('#qty', 'record.quantity__m', { type: 'number' });
  2. Currency

    Pass type:'currency' with the owning object and the binding renders the correct currency symbol automatically.

    currency
    ctx.bind('#total', 'invoice.total__m', {
      type:   'currency',
      object: 'invoice__m'   // auto-resolves the symbol
    });
  3. Phone

    type:'phone' gives you an automatic country-code dropdown alongside the number input.

    phone
    ctx.bind('#phone', 'record.phone__m', { type: 'phone' });
  4. Date

    A date bind auto-parses the MongoDB date envelope stored on the record — you point it at the field and it handles the format.

    date
    ctx.bind('#due', 'record.due__m'); // auto-parses Mongo dates
  5. Boolean (checkbox)

    Bind a checkbox straight to a boolean field; the checked state and the stored value track each other.

    boolean
    ctx.bind('#active', 'record.is_active__m');
  6. Picklist

    type:'picklist' with an options array renders the choices and stores the selected one.

    picklist
    ctx.bind('#status', 'record.status__m', {
      type:    'picklist',
      options: ['Active', 'Inactive']
    });
  7. Lookup

    type:'lookup' takes the target object and a displayField; the user searches records, and the binding stores a {_id, name} object — exactly the shape the platform expects for a relationship.

    lookup
    ctx.bind('#customer', 'record.customer__m', {
      type:         'lookup',
      object:       'customer__m',
      displayField: 'name'      // stores { _id, name }
    });
You rarely write the type

ctx.bind auto-resolves the field type from the declared variable. If invoice.total__m is declared as a currency, or record.customer__m as a lookup, you can drop {type:'currency'} / {type:'lookup'} entirely — the binding already knows. The explicit forms above are shown so you recognise the option when you do need to override it (for example, a picklist whose options are computed at runtime).

Unit 3 of 5

Buttons are binds too

A button is just ctx.bind on a 'click' with an invoke spec — no async boilerplate, no manual loading flags, no error handling to wire.

Instead of a data path, you pass 'click' and an options object that describes the call. Here's the full shape, saving an invoice:

save button
ctx.bind('#save', 'click', {
  invoke:      'saveInvoice__fx',
  params:      [{ name: 'invoice', value: 'invoice' }],
  loadingWhen: 'isSaving',
  responseMap: { result: 'saveResult' },
  onSuccess:   (r) => ctx.toast('Saved', 'success')
});

Reading that top to bottom:

  • invoke — the function to run. It carries the __fx suffix because it's wired to the page.
  • params — an array of {name, value}. The name must match the function's declared input; the value is a pageData path. Where the two are identical you can use the shorthand ['invoice'], which uses the path as both name and value.
  • loadingWhen — a boolean path the binding toggles for you while the call is in flight; the button auto-disables and shows a spinner, then clears when it returns.
  • responseMap — maps fields off the function's response into pageData (here the response's result lands at saveResult).
  • onSuccess — a callback that runs after a clean response. Errors are auto-toasted, so you only write the happy path.
Invokes are validated at SAVE

When you save the page, every invoke is checked: the function must exist, be compiled and active, and the params names must match its declared inputs. A typo in the function name or a param that the function doesn't accept is a save-time error, not a silent runtime failure — you find out before users do.

Unit 4 of 5

Dynamic options

Bindings react to state through their options. The trick is that the *When options take a path, not a fixed value — so the field responds as the data underneath it changes.

The reactive options:

  • disabledWhen — a path to a boolean. While that path is truthy the field is disabled; flip the boolean and it enables itself.
  • requiredWhen — a path to a boolean that makes the field conditionally required.
  • required — a hard true that simply sets the HTML required attribute.
  • onChange — invokes a function when the field loses focus (on blur), handy for recalculating or validating as the user moves on.

Because disabledWhen and requiredWhen point at data, you never re-run code to toggle them — set the flag in pageData (or let a function set it) and the fields follow:

reactive options
// 'isLocked' and 'needsReason' are boolean paths in pageData
ctx.bind('#amount', 'record.amount__m', {
  type:         'currency',
  object:       'invoice__m',
  disabledWhen: 'isLocked'
});

ctx.bind('#reason', 'record.reason__m', {
  requiredWhen: 'needsReason',
  placeholder:  'Why was this changed?'
});

Other options you'll reach for: placeholder for empty-state text, and defaultCode to seed an initial value.

UI rules are a courtesy, not the guard

Encode rules in the interface with disabledWhen / requiredWhen / required so the form guides the user — but the server function on save must still enforce them. A disabled field can be bypassed; the function is the real boundary. (That's the same lesson from the build trail: validate where it counts.)

Unit 5 of 5

Custom & loop handlers

For clicks inside {|#each|} loops — or any custom interaction — you don't use ctx.bind. You use a delegated ctx.on with a string selector.

The difference is where the listener lives. ctx.on('.row','click', …) attaches to the container and matches its descendants by selector. Because it isn't bound to a specific node, it survives innerHTML re-renders and matches rows that are loaded later — exactly what a loop needs. Read the row's identity from a data attribute via e.currentTarget.getAttribute('data-id'):

loop row + delegated handler
<!-- in the page HTML: a row per record, carrying its id -->
{|#each records|}
  <tr class="row" data-id="{|._id|}">
    <td>{|.name|}</td>
    <td><button class="row-open">Open</button></td>
  </tr>
{|/each|}

// in the page script: ONE delegated handler for every row
ctx.on('.row', 'click', (e) => {
  const id = e.currentTarget.getAttribute('data-id');
  ctx.set('selectedId', id);
});

The rules that keep loop handlers working:

  • Pass a string selector — never a cached node. The match is re-evaluated on every event, so re-rendered rows still fire.
  • Never addEventListener on individual rows; they vanish on the next render and take your listener with them.
  • Never use inline onclick attributes — Angular strips them, so they simply won't fire.
Where to reach for each

ctx.bind for static fields and buttons (resolved once). ctx.on for anything inside a loop or conditional, and for custom interactions you wire by hand. Get this split right and your page survives every re-render.

Next up — the ctx API

You've met bind, on, set, get and toast in passing. Module R4 is the full tour of the ctx object — every method a page script can call. Unsure of a term along the way? The Glossary has it.

Checkpoint

Earn the Binder stamp

Five questions to lock in Module R3.