Skip to content
Mercury
English
Design Book
Menu

Reflect.1. Binding a view

A reflected view is still a component. Stores provide its values; bound actions provide its callbacks. Start with a name field and a counter so both directions are visible.

Reflect 4.1.1 · Interactive exercise

Open the exercise on its own page

Explain the connection

The view does not need to know the model. CounterView below receives a number and a callback; it can also be rendered directly with test props. reflect supplies defaults from the model and returns a new component. Define the model and reflected components outside render, so ordinary rerenders do not create new units or constructors.

import {createElement as h} from "@fiberfx/hyperscript";
import {event, store} from "@fiberfx/hypergraph";
import {reflect, createReflect, fromTag} from "@fiberfx/reflect";

const nameChanged = event<string>();
const incremented = event<void>();
const $name = store("Ada").on(nameChanged, (_, name) => name);
const $count = store(0).on(incremented, count => count + 1);

export const Name = reflect({
  view: fromTag("input"),
  bind: {
    "aria-label": "Name",
    value: $name,
    onInput: (e: Event) =>
      nameChanged((e.currentTarget as HTMLInputElement).value),
  },
});
const CounterView = (p: {count: number; onIncrement: () => void}) =>
  h("button", {type: "button", onClick: p.onIncrement}, String(p.count));
const counter = createReflect(CounterView);
export const Counter = counter({count: $count, onIncrement: () => incremented()});

fromTag("input") adapts an HTML tag to a view while retaining its known prop types. createReflect(CounterView) fixes the view and lets you make several bindings with the same shape. It is equivalent to repeatedly calling reflect with that view.

A native input handler receives an event, so the adapter extracts currentTarget.value. A Mercury Checkbox's onChange receives a boolean. Reflect does not convert either payload. Read inputs and forms before swapping a control. Render these components under the application's Hypergraph Provider; chapter 3 explains its ownership.

Manipulate the example

Open the binding experiment. Replace the draft name and choose Apply name, then increment the counter twice. Change the draft once more without applying it. The field and the applied-name output represent different model values. The lab uses Mercury Input through createReflect and renders the count through fromTag("output"); the smaller snippet above illustrates the same operators with native input and a button view.

For a code variation, render h(Counter, {count: 99}). The caller now supplies the displayed number while the bound increment action remains active. This is an explicit override, not a change to $count.

Observe both directions

Typing should update the draft-name store and field. Applying a nonempty name should update the separate applied-name output. Clicking Add one should update the counter store and its output. A store is read as a value; the view never receives the Store object itself.

With the count: 99 variation, the button remains at 99 even when its action changes the underlying store. Remove the override and the latest store value becomes visible. An incoming handler similarly replaces a bound handler; Reflect does not call both.

Try a violating binding

First empty the draft and choose Apply name. The lab should show a validation message and retain the last applied name. That refusal belongs to the model's sample filter, not to Reflect.

A separate type-level mistake is confusing a string payload with a DOM event. This binding must be rejected by the type checker:

reflect({view: fromTag("input"), bind: {
  onInput: nameChanged, // wrong: event<string> cannot consume an input Event
}});

Use the explicit adapter from the example. For a payload-free action, write () => incremented() rather than passing whatever a native click supplies. Reflect is a binding layer, not a runtime payload validator; bypassing types does not make an incompatible callback safe.

Reset and repeat

Use the experiment's reset to return to its initial values. Type a different name and perform one increment. Resetting the exercise restores its starting state; it does not undo arbitrary application side effects.

When resetting your own fixture, restore model values or recreate its owned scope and unmount old views. Do not make a new reflected constructor inside render just to clear a field.

Transfer it to a real form

Bind a search field with value: $query and an adapter that sends a string to queryChanged. Keep validation and search effects in the model. Render the ordinary input view directly once with fixed props to confirm its event contract.

You are ready to continue when you can identify the two boundaries separately: DOM event → model payload, and store value → view prop. Next, derive a display prop from several inputs without creating graph nodes while rendering.

Previous · Course · Next