Core concepts

Forms and Two-Way Binding

One-way value+oninput, two-way binding with bindings.model and x-model, select, form submission with onsubmit and a function action, a validation pattern; with and without JSX.

MotifJS handles form input in two ways:

  1. Reactive read + event write (one-way, explicit): value={() => note.title} + oninput.
  2. A two-way binding (bindings.model, x-model in JSX): the input and the model stay in sync automatically. For a one-way binding to a specific DOM property, use bindings.add.

The examples are slices of the notes application. Note and NotesService are the ones in the src/services/notes.ts file on the Components page, and SettingsService and THEMES come from the src/services/settings.ts file on the Getting Started page; note is the note being edited.

The one-way (explicit) approach

You read the value reactively and write the change to the model with an event. This is the route to take when a change involves extra work (conversion, validation, a side effect):

<form>
  <input type="text" value={() => note.title} oninput={(e) => (note.title = (e.target as HTMLInputElement).value)} />
  <input type="checkbox" checked={() => note.pinned} onchange={(e) => (note.pinned = (e.target as HTMLInputElement).checked)} />
</form>
motifComponent("form", (form: ComponentBase) => {
  const title = motifComponent("input", (input: ComponentBase) => {
    input.attr.add({ type: "text" });
    input.bindings.add("value", () => note.title);
    input.motif.on("input", (_sender: ComponentBase, e: Event) => (note.title = (e.target as HTMLInputElement).value));
  });
  const pinned = motifComponent("input", (input: ComponentBase) => {
    input.attr.add({ type: "checkbox" });
    input.bindings.add("checked", () => note.pinned);
    input.motif.on("change", (_sender: ComponentBase, e: Event) => (note.pinned = (e.target as HTMLInputElement).checked));
  });
  form.controls.add(title, pinned);
});
<form>
  <input type="text" value={() => note.title} oninput={(e) => (note.title = e.target.value)} />
  <input type="checkbox" checked={() => note.pinned} onchange={(e) => (note.pinned = e.target.checked)} />
</form>
motifComponent("form", (form) => {
  const title = motifComponent("input", (input) => {
    input.attr.add({ type: "text" });
    input.bindings.add("value", () => note.title);
    input.motif.on("input", (_sender, e) => (note.title = e.target.value));
  });
  const pinned = motifComponent("input", (input) => {
    input.attr.add({ type: "checkbox" });
    input.bindings.add("checked", () => note.pinned);
    input.motif.on("change", (_sender, e) => (note.pinned = e.target.checked));
  });
  form.controls.add(title, pinned);
});

value and checked are property bindings: a value={() => …} written on a tag in JSX compiles to a bindings.add("value", getter) call, and no attribute is written.

Two-way binding — bindings.model

bindings.model picks the right DOM property automatically based on the type of the element and binds it to the model in both directions:

Element Bound property
<input type="checkbox">, <input type="radio"> checked (boolean)
<input> (others) value
<select> value
<textarea> value
<img>, <audio>, <video> src (one-way: model → element only)

On inputs, the choice between checked and value is made not when the binding is set up but on every read and write, based on the element's current type: when onConfig sets up the binding, the type attribute may not have been applied yet.

The type of the value written to the model is also decided by the element:

Input Written to the model
checkbox, radio boolean
number, range number (null if the input is emptied)
everything else (text, date, select, textarea…) string

Date types stay strings on purpose: their natural model is the '2026-10-16' form.

<>
  <input type="text" onConfig={(field) => field.bindings.model(note, "title")} />
  <input type="checkbox" onConfig={(box) => box.bindings.model(note, "pinned")} />
</>
const title = motifComponent("input", (field: ComponentBase) => {
  field.attr.add({ type: "text" });
  field.bindings.model(note, "title");
});
const pinned = motifComponent("input", (box: ComponentBase) => {
  box.attr.add({ type: "checkbox" });
  box.bindings.model(note, "pinned");
});
<>
  <input type="text" onConfig={(field) => field.bindings.model(note, "title")} />
  <input type="checkbox" onConfig={(box) => box.bindings.model(note, "pinned")} />
</>
const title = motifComponent("input", (field) => {
  field.attr.add({ type: "text" });
  field.bindings.model(note, "title");
});
const pinned = motifComponent("input", (box) => {
  box.attr.add({ type: "checkbox" });
  box.bindings.model(note, "pinned");
});

In JSX the binding is set up in onConfig: it is added while the component is being configured. Without JSX, the setup function itself is that place.

Signatures:

Call Bound value
model(dataSource, dataMember) The dataMember field on dataSource
model(dataSource, dataMember, formatString) The same field, formatted with formatString
model(dataSource, dataMember, formatString, formatInfo) The same field; locale and currency come from formatInfo ({ locale, currency })
model(getter, setter) Reads with getter(), writes with setter(value)
model(binding) A ready binding object

Every form returns the added binding (IBaseBinding).

dataMember can also be a dotted path ("meta.category"). In a call with one argument, an object is interpreted as an IBaseBinding, while a function given as the single argument only reads, binding one-way. model(dataSource, dataMember) keeps the dataSource object as it was at the time of the call; if that object can be replaced as a whole, use x-model or the getter/setter form.

The x-model shorthand

x-model compiles to a bindings.model(getter, setter) call and is two-way; without JSX you write the getter and the setter yourself:

<>
  <input type="text" x-model={() => note.title} />
  <input type="checkbox" x-model={() => note.pinned} />
</>
const title = motifComponent("input", (input: ComponentBase) => {
  input.attr.add({ type: "text" });
  input.bindings.model(() => note.title, (value: string) => (note.title = value));
});
const pinned = motifComponent("input", (input: ComponentBase) => {
  input.attr.add({ type: "checkbox" });
  input.bindings.model(() => note.pinned, (value: boolean) => (note.pinned = value));
});
<>
  <input type="text" x-model={() => note.title} />
  <input type="checkbox" x-model={() => note.pinned} />
</>
const title = motifComponent("input", (input) => {
  input.attr.add({ type: "text" });
  input.bindings.model(() => note.title, (value) => (note.title = value));
});
const pinned = motifComponent("input", (input) => {
  input.attr.add({ type: "checkbox" });
  input.bindings.model(() => note.pinned, (value) => (note.pinned = value));
});

The setter the compiler produces reads the object that owns the member again on every write (note for note.title, row.note for row.note.title, state.selected for state.selected.title). That way the binding does not break when the object is replaced as a whole (state.selected = otherNote) or when list rows change places:

{notes.list("notes").map((note) => <input key={note.id} type="text" x-model={() => note.title} />)}
motifComponent({
  initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("notes"), (note: Note) => motifComponent("input", {
    indexkey: () => note.id,
    initializeComponent: (field: ComponentBase) => {
      field.attr.add({ type: "text" });
      field.bindings.model(() => note.title, (value: string) => (note.title = value));
    },
  })),
});
{notes.list("notes").map((note) => <input key={note.id} type="text" x-model={() => note.title} />)}
motifComponent({
  initializeComponent: (holder) => holder.bindings.list(() => notes.list("notes"), (note) => motifComponent("input", {
    indexkey: () => note.id,
    initializeComponent: (field) => {
      field.attr.add({ type: "text" });
      field.bindings.model(() => note.title, (value) => (note.title = value));
    },
  })),
});

The write is skipped while the owner is null/undefined (x-model={() => state.selected?.title}) and while the member holds a function (a getter prop). An expression without member access (() => a + b, () => fn(x)) is only read and stays one-way; motif-explain shows which case applies.

x-model on your own component

A component whose element is not a form element can also be used with x-model. The bound value is written to the component's value property; if the component defines its own value property, the value goes to the component even when its element is something with a value field of its own, such as a <button>. The component writes the new value back to the model with bindings.writeModel(value) (this.bindings in a class and in Options, the bindings of the root component in a function component). PinSwitch is a button that turns pinning a note on and off: in the class and in Options, value is an accessor (get/set), and in the function the accessor is set up with Object.defineProperty in the root's initializeComponent hook:

import { Component, reactive } from "@motifx/core";

export class PinSwitch extends Component<HTMLButtonElement> {
  state = reactive({ on: false });

  set value(v: boolean) { this.state.on = !!v; }
  get value() { return this.state.on; }

  override onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.state.on));
  }

  override view() {
    return <>{() => (this.state.on ? "Fixed" : "Free")}</>;
  }
}
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";

export class PinSwitch extends Component<HTMLButtonElement> {
  static override elementTag = "button";
  state = reactive({ on: false });

  set value(v: boolean) { this.state.on = !!v; }
  get value() { return this.state.on; }

  override onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.state.on));
  }

  override view() {
    return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => (this.state.on ? "Fixed" : "Free")));
  }
}
import { reactive } from "@motifx/core";

export function PinSwitch() {
  const state = reactive({ on: false });
  return (
    <button
      type="button"
      class="switch"
      initializeComponent={(root) => Object.defineProperty(root, "value", { get: () => state.on, set: (v: boolean) => (state.on = !!v), configurable: true })}
      onclick={(root, _e) => root.bindings.writeModel(!state.on)}
    >
      {() => (state.on ? "Fixed" : "Free")}
    </button>
  );
}
import { ComponentBase, motifComponent, reactive } from "@motifx/core";

export function PinSwitch() {
  const state = reactive({ on: false });
  return motifComponent("button", (root: ComponentBase) => {
    Object.defineProperty(root, "value", { get: () => state.on, set: (v: boolean) => (state.on = !!v), configurable: true });
    root.attr.add({ type: "button" });
    root.class.add("switch");
    root.motif.on("click", () => root.bindings.writeModel(!state.on));
    root.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.text(() => (state.on ? "Fixed" : "Free"))));
  });
}
import { motifOptions, reactive } from "@motifx/core";

export const PinSwitch = () => motifOptions({
  el: "button",
  data: reactive({ on: false }),

  set value(v: boolean) { this.data.on = !!v; },
  get value() { return this.data.on; },

  onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.data.on));
  },

  view() {
    return <>{() => (this.data.on ? "Fixed" : "Free")}</>;
  },
});
import { ComponentBase, motifComponent, motifOptions, reactive } from "@motifx/core";

export const PinSwitch = () => motifOptions({
  el: "button",
  data: reactive({ on: false }),

  set value(v: boolean) { this.data.on = !!v; },
  get value() { return this.data.on; },

  onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.data.on));
  },

  view() {
    return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => (this.data.on ? "Fixed" : "Free")));
  },
});
import { Component, reactive } from "@motifx/core";

export class PinSwitch extends Component {
  static elementTag = "button";
  state = reactive({ on: false });

  set value(v) { this.state.on = !!v; }
  get value() { return this.state.on; }

  onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.state.on));
  }

  view() {
    return <>{() => (this.state.on ? "Fixed" : "Free")}</>;
  }
}
import { Component, motifComponent, reactive } from "@motifx/core";

export class PinSwitch extends Component {
  static elementTag = "button";
  state = reactive({ on: false });

  set value(v) { this.state.on = !!v; }
  get value() { return this.state.on; }

  onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.state.on));
  }

  view() {
    return motifComponent("text", (node) => node.bindings.text(() => (this.state.on ? "Fixed" : "Free")));
  }
}
import { reactive } from "@motifx/core";

export function PinSwitch() {
  const state = reactive({ on: false });
  return (
    <button
      type="button"
      class="switch"
      initializeComponent={(root) => Object.defineProperty(root, "value", { get: () => state.on, set: (v) => (state.on = !!v), configurable: true })}
      onclick={(root, _e) => root.bindings.writeModel(!state.on)}
    >
      {() => (state.on ? "Fixed" : "Free")}
    </button>
  );
}
import { motifComponent, reactive } from "@motifx/core";

export function PinSwitch() {
  const state = reactive({ on: false });
  return motifComponent("button", (root) => {
    Object.defineProperty(root, "value", { get: () => state.on, set: (v) => (state.on = !!v), configurable: true });
    root.attr.add({ type: "button" });
    root.class.add("switch");
    root.motif.on("click", () => root.bindings.writeModel(!state.on));
    root.controls.add(motifComponent("text", (node) => node.bindings.text(() => (state.on ? "Fixed" : "Free"))));
  });
}
import { motifOptions, reactive } from "@motifx/core";

export const PinSwitch = () => motifOptions({
  el: "button",
  data: reactive({ on: false }),

  set value(v) { this.data.on = !!v; },
  get value() { return this.data.on; },

  onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.data.on));
  },

  view() {
    return <>{() => (this.data.on ? "Fixed" : "Free")}</>;
  },
});
import { motifComponent, motifOptions, reactive } from "@motifx/core";

export const PinSwitch = () => motifOptions({
  el: "button",
  data: reactive({ on: false }),

  set value(v) { this.data.on = !!v; },
  get value() { return this.data.on; },

  onConfigured() {
    this.attr.add({ type: "button" });
    this.class.add("switch");
    this.motif.on("click", () => this.bindings.writeModel(!this.data.on));
  },

  view() {
    return motifComponent("text", (node) => node.bindings.text(() => (this.data.on ? "Fixed" : "Free")));
  },
});
<PinSwitch x-model={() => note.pinned} />
const pin = motifComponent(PinSwitch);
pin.bindings.model(() => note.pinned, (value: boolean) => (note.pinned = value));
<PinSwitch x-model={() => note.pinned} />
const pin = motifComponent(PinSwitch);
pin.bindings.model(() => note.pinned, (value) => (note.pinned = value));

writeModel writes to every binding set up on the component with bindings.model or x-model; converterBack is applied if there is one, and the write path is the same as on form elements. If there is no binding, it writes nothing and returns false, so the component can be used without x-model too. It does not touch bindings set up with bindings.add.

Binding a specific property with bindings.add

If you want to specify which DOM property to bind yourself, use bindings.add. This binding is one-way (model → DOM); to write the user's change to the model, add an event handler:

<input type="checkbox" onConfig={(box) => box.bindings.add("checked", note, "pinned")} onchange={(e) => (note.pinned = (e.target as HTMLInputElement).checked)} />
motifComponent("input", (box: ComponentBase) => {
  box.attr.add({ type: "checkbox" });
  box.bindings.add("checked", note, "pinned");
  box.motif.on("change", (_sender: ComponentBase, e: Event) => (note.pinned = (e.target as HTMLInputElement).checked));
});
<input type="checkbox" onConfig={(box) => box.bindings.add("checked", note, "pinned")} onchange={(e) => (note.pinned = e.target.checked)} />
motifComponent("input", (box) => {
  box.attr.add({ type: "checkbox" });
  box.bindings.add("checked", note, "pinned");
  box.motif.on("change", (_sender, e) => (note.pinned = e.target.checked));
});

When checked, selected, muted, indeterminate and srcObject are written as attributes, they are assigned as element properties (!!value), not as HTML attributes: <input type="checkbox" indeterminate={() => selection.partial} />.

Text input and value

For <input> and <textarea> the value prop is recognized specially; it takes a reactive value or a getter:

<>
  <input type="text" value={() => note.title} oninput={(e) => (note.title = (e.target as HTMLInputElement).value)} />
  <textarea value={() => note.body} oninput={(e) => (note.body = (e.target as HTMLTextAreaElement).value)} />
</>
const title = motifComponent("input", (input: ComponentBase) => {
  input.attr.add({ type: "text" });
  input.bindings.add("value", () => note.title);
  input.motif.on("input", (_sender: ComponentBase, e: Event) => (note.title = (e.target as HTMLInputElement).value));
});
const body = motifComponent("textarea", (area: ComponentBase) => {
  area.bindings.add("value", () => note.body);
  area.motif.on("input", (_sender: ComponentBase, e: Event) => (note.body = (e.target as HTMLTextAreaElement).value));
});
<>
  <input type="text" value={() => note.title} oninput={(e) => (note.title = e.target.value)} />
  <textarea value={() => note.body} oninput={(e) => (note.body = e.target.value)} />
</>
const title = motifComponent("input", (input) => {
  input.attr.add({ type: "text" });
  input.bindings.add("value", () => note.title);
  input.motif.on("input", (_sender, e) => (note.title = e.target.value));
});
const body = motifComponent("textarea", (area) => {
  area.bindings.add("value", () => note.body);
  area.motif.on("input", (_sender, e) => (note.body = e.target.value));
});

A <select> example

The theme choice is written to the settings service. The change goes through setTheme, because the service applies the theme to <html> and saves it:

<select value={() => settings.state.theme} onchange={(e) => settings.setTheme((e.target as HTMLSelectElement).value as Theme)}>
  <option value="system">System</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>
motifComponent("select", (select: ComponentBase) => {
  select.bindings.add("value", () => settings.state.theme);
  select.motif.on("change", (_sender: ComponentBase, e: Event) => settings.setTheme((e.target as HTMLSelectElement).value as Theme));
  for (const [value, label] of [["system", "System"], ["light", "Light"], ["dark", "Dark"]]) {
    select.controls.add(motifComponent("option", (option: ComponentBase) => {
      option.attr.add({ value });
      option.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(label)));
    }));
  }
});
<select value={() => settings.state.theme} onchange={(e) => settings.setTheme(e.target.value)}>
  <option value="system">System</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>
motifComponent("select", (select) => {
  select.bindings.add("value", () => settings.state.theme);
  select.motif.on("change", (_sender, e) => settings.setTheme(e.target.value));
  for (const [value, label] of [["system", "System"], ["light", "Light"], ["dark", "Dark"]]) {
    select.controls.add(motifComponent("option", (option) => {
      option.attr.add({ value });
      option.controls.add(motifComponent("text", (node) => node.setText(label)));
    }));
  }
});

<select x-model={() => settings.state.theme}> binds both ways too, but it writes to the field directly and skips the applying and saving that setTheme does.

Form submission

NoteForm binds the fields of a new note to a draft with x-model and writes it to the service on submit. onsubmit:prevent keeps the page from reloading; without JSX the same modifier is given with motif.on("submit:prevent", …):

import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export class NoteForm extends Component {
  notes = this.getService(NotesService)!;
  draft = reactive({ title: "", body: "", pinned: false, due: "" });

  save() {
    const note = this.notes.create();
    note.title = this.draft.title;
    note.body = this.draft.body;
    note.pinned = this.draft.pinned;
    this.draft.title = "";
    this.draft.body = "";
    this.draft.due = "";
  }

  override view() {
    return (
      <form class="note-form" onsubmit:prevent={() => this.save()}>
        <input type="text" placeholder="Title" x-model={() => this.draft.title} />
        <textarea placeholder="Body" x-model={() => this.draft.body} />
        <label><input type="checkbox" x-model={() => this.draft.pinned} /> Pin</label>
        <input type="date" x-model={() => this.draft.due} />
        <button type="submit" disabled={() => !this.draft.title.trim()}>Save</button>
      </form>
    );
  }
}
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export class NoteForm extends Component {
  notes = this.getService(NotesService)!;
  draft = reactive({ title: "", body: "", pinned: false, due: "" });

  save() {
    const note = this.notes.create();
    note.title = this.draft.title;
    note.body = this.draft.body;
    note.pinned = this.draft.pinned;
    this.draft.title = "";
    this.draft.body = "";
    this.draft.due = "";
  }

  override view() {
    return motifComponent("form", (form: ComponentBase) => {
      form.class.add("note-form");
      form.motif.on("submit:prevent", () => this.save());
      const title = motifComponent("input", (input: ComponentBase) => {
        input.attr.add({ type: "text", placeholder: "Title" });
        input.bindings.model(() => this.draft.title, (value: string) => (this.draft.title = value));
      });
      const body = motifComponent("textarea", (area: ComponentBase) => {
        area.attr.add({ placeholder: "Body" });
        area.bindings.model(() => this.draft.body, (value: string) => (this.draft.body = value));
      });
      const pinned = motifComponent("label", (label: ComponentBase) => {
        label.controls.add(
          motifComponent("input", (box: ComponentBase) => {
            box.attr.add({ type: "checkbox" });
            box.bindings.model(() => this.draft.pinned, (value: boolean) => (this.draft.pinned = value));
          }),
          motifComponent("text", (node: ComponentBase) => node.setText(" Pin")),
        );
      });
      const due = motifComponent("input", (input: ComponentBase) => {
        input.attr.add({ type: "date" });
        input.bindings.model(() => this.draft.due, (value: string) => (this.draft.due = value));
      });
      const submit = motifComponent("button", (button: ComponentBase) => {
        button.attr.add({ type: "submit", disabled: () => !this.draft.title.trim() });
        button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Save")));
      });
      form.controls.add(title, body, pinned, due, submit);
    });
  }
}
import { FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteForm() {
  const notes = FromService(NotesService)!;
  const draft = reactive({ title: "", body: "", pinned: false, due: "" });

  const save = () => {
    const note = notes.create();
    note.title = draft.title;
    note.body = draft.body;
    note.pinned = draft.pinned;
    draft.title = "";
    draft.body = "";
    draft.due = "";
  };

  return (
    <form class="note-form" onsubmit:prevent={save}>
      <input type="text" placeholder="Title" x-model={() => draft.title} />
      <textarea placeholder="Body" x-model={() => draft.body} />
      <label><input type="checkbox" x-model={() => draft.pinned} /> Pin</label>
      <input type="date" x-model={() => draft.due} />
      <button type="submit" disabled={() => !draft.title.trim()}>Save</button>
    </form>
  );
}
import { ComponentBase, FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteForm() {
  const notes = FromService(NotesService)!;
  const draft = reactive({ title: "", body: "", pinned: false, due: "" });

  const save = () => {
    const note = notes.create();
    note.title = draft.title;
    note.body = draft.body;
    note.pinned = draft.pinned;
    draft.title = "";
    draft.body = "";
    draft.due = "";
  };

  return motifComponent("form", (form: ComponentBase) => {
    form.class.add("note-form");
    form.motif.on("submit:prevent", save);
    const title = motifComponent("input", (input: ComponentBase) => {
      input.attr.add({ type: "text", placeholder: "Title" });
      input.bindings.model(() => draft.title, (value: string) => (draft.title = value));
    });
    const body = motifComponent("textarea", (area: ComponentBase) => {
      area.attr.add({ placeholder: "Body" });
      area.bindings.model(() => draft.body, (value: string) => (draft.body = value));
    });
    const pinned = motifComponent("label", (label: ComponentBase) => {
      label.controls.add(
        motifComponent("input", (box: ComponentBase) => {
          box.attr.add({ type: "checkbox" });
          box.bindings.model(() => draft.pinned, (value: boolean) => (draft.pinned = value));
        }),
        motifComponent("text", (node: ComponentBase) => node.setText(" Pin")),
      );
    });
    const due = motifComponent("input", (input: ComponentBase) => {
      input.attr.add({ type: "date" });
      input.bindings.model(() => draft.due, (value: string) => (draft.due = value));
    });
    const submit = motifComponent("button", (button: ComponentBase) => {
      button.attr.add({ type: "submit", disabled: () => !draft.title.trim() });
      button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Save")));
    });
    form.controls.add(title, body, pinned, due, submit);
  });
}
import { motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteForm = () => motifOptions({
  el: "form",
  data: reactive({ title: "", body: "", pinned: false, due: "" }),

  get notes() {
    return this.getService(NotesService)!;
  },

  save() {
    const note = this.notes.create();
    note.title = this.data.title;
    note.body = this.data.body;
    note.pinned = this.data.pinned;
    this.data.title = "";
    this.data.body = "";
    this.data.due = "";
  },

  onConfigured() {
    this.class.add("note-form");
    this.motif.on("submit:prevent", () => this.save());
  },

  view() {
    return (
      <>
        <input type="text" placeholder="Title" x-model={() => this.data.title} />
        <textarea placeholder="Body" x-model={() => this.data.body} />
        <label><input type="checkbox" x-model={() => this.data.pinned} /> Pin</label>
        <input type="date" x-model={() => this.data.due} />
        <button type="submit" disabled={() => !this.data.title.trim()}>Save</button>
      </>
    );
  },
});
import { ComponentBase, motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteForm = () => motifOptions({
  el: "form",
  data: reactive({ title: "", body: "", pinned: false, due: "" }),

  get notes() {
    return this.getService(NotesService)!;
  },

  save() {
    const note = this.notes.create();
    note.title = this.data.title;
    note.body = this.data.body;
    note.pinned = this.data.pinned;
    this.data.title = "";
    this.data.body = "";
    this.data.due = "";
  },

  onConfigured() {
    this.class.add("note-form");
    this.motif.on("submit:prevent", () => this.save());
  },

  view() {
    const title = motifComponent("input", (input: ComponentBase) => {
      input.attr.add({ type: "text", placeholder: "Title" });
      input.bindings.model(() => this.data.title, (value: string) => (this.data.title = value));
    });
    const body = motifComponent("textarea", (area: ComponentBase) => {
      area.attr.add({ placeholder: "Body" });
      area.bindings.model(() => this.data.body, (value: string) => (this.data.body = value));
    });
    const pinned = motifComponent("label", (label: ComponentBase) => {
      label.controls.add(
        motifComponent("input", (box: ComponentBase) => {
          box.attr.add({ type: "checkbox" });
          box.bindings.model(() => this.data.pinned, (value: boolean) => (this.data.pinned = value));
        }),
        motifComponent("text", (node: ComponentBase) => node.setText(" Pin")),
      );
    });
    const due = motifComponent("input", (input: ComponentBase) => {
      input.attr.add({ type: "date" });
      input.bindings.model(() => this.data.due, (value: string) => (this.data.due = value));
    });
    const submit = motifComponent("button", (button: ComponentBase) => {
      button.attr.add({ type: "submit", disabled: () => !this.data.title.trim() });
      button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Save")));
    });
    return [title, body, pinned, due, submit];
  },
});
import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export class NoteForm extends Component {
  notes = this.getService(NotesService);
  draft = reactive({ title: "", body: "", pinned: false, due: "" });

  save() {
    const note = this.notes.create();
    note.title = this.draft.title;
    note.body = this.draft.body;
    note.pinned = this.draft.pinned;
    this.draft.title = "";
    this.draft.body = "";
    this.draft.due = "";
  }

  view() {
    return (
      <form class="note-form" onsubmit:prevent={() => this.save()}>
        <input type="text" placeholder="Title" x-model={() => this.draft.title} />
        <textarea placeholder="Body" x-model={() => this.draft.body} />
        <label><input type="checkbox" x-model={() => this.draft.pinned} /> Pin</label>
        <input type="date" x-model={() => this.draft.due} />
        <button type="submit" disabled={() => !this.draft.title.trim()}>Save</button>
      </form>
    );
  }
}
import { Component, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export class NoteForm extends Component {
  notes = this.getService(NotesService);
  draft = reactive({ title: "", body: "", pinned: false, due: "" });

  save() {
    const note = this.notes.create();
    note.title = this.draft.title;
    note.body = this.draft.body;
    note.pinned = this.draft.pinned;
    this.draft.title = "";
    this.draft.body = "";
    this.draft.due = "";
  }

  view() {
    return motifComponent("form", (form) => {
      form.class.add("note-form");
      form.motif.on("submit:prevent", () => this.save());
      const title = motifComponent("input", (input) => {
        input.attr.add({ type: "text", placeholder: "Title" });
        input.bindings.model(() => this.draft.title, (value) => (this.draft.title = value));
      });
      const body = motifComponent("textarea", (area) => {
        area.attr.add({ placeholder: "Body" });
        area.bindings.model(() => this.draft.body, (value) => (this.draft.body = value));
      });
      const pinned = motifComponent("label", (label) => {
        label.controls.add(
          motifComponent("input", (box) => {
            box.attr.add({ type: "checkbox" });
            box.bindings.model(() => this.draft.pinned, (value) => (this.draft.pinned = value));
          }),
          motifComponent("text", (node) => node.setText(" Pin")),
        );
      });
      const due = motifComponent("input", (input) => {
        input.attr.add({ type: "date" });
        input.bindings.model(() => this.draft.due, (value) => (this.draft.due = value));
      });
      const submit = motifComponent("button", (button) => {
        button.attr.add({ type: "submit", disabled: () => !this.draft.title.trim() });
        button.controls.add(motifComponent("text", (node) => node.setText("Save")));
      });
      form.controls.add(title, body, pinned, due, submit);
    });
  }
}
import { FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteForm() {
  const notes = FromService(NotesService);
  const draft = reactive({ title: "", body: "", pinned: false, due: "" });

  const save = () => {
    const note = notes.create();
    note.title = draft.title;
    note.body = draft.body;
    note.pinned = draft.pinned;
    draft.title = "";
    draft.body = "";
    draft.due = "";
  };

  return (
    <form class="note-form" onsubmit:prevent={save}>
      <input type="text" placeholder="Title" x-model={() => draft.title} />
      <textarea placeholder="Body" x-model={() => draft.body} />
      <label><input type="checkbox" x-model={() => draft.pinned} /> Pin</label>
      <input type="date" x-model={() => draft.due} />
      <button type="submit" disabled={() => !draft.title.trim()}>Save</button>
    </form>
  );
}
import { FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteForm() {
  const notes = FromService(NotesService);
  const draft = reactive({ title: "", body: "", pinned: false, due: "" });

  const save = () => {
    const note = notes.create();
    note.title = draft.title;
    note.body = draft.body;
    note.pinned = draft.pinned;
    draft.title = "";
    draft.body = "";
    draft.due = "";
  };

  return motifComponent("form", (form) => {
    form.class.add("note-form");
    form.motif.on("submit:prevent", save);
    const title = motifComponent("input", (input) => {
      input.attr.add({ type: "text", placeholder: "Title" });
      input.bindings.model(() => draft.title, (value) => (draft.title = value));
    });
    const body = motifComponent("textarea", (area) => {
      area.attr.add({ placeholder: "Body" });
      area.bindings.model(() => draft.body, (value) => (draft.body = value));
    });
    const pinned = motifComponent("label", (label) => {
      label.controls.add(
        motifComponent("input", (box) => {
          box.attr.add({ type: "checkbox" });
          box.bindings.model(() => draft.pinned, (value) => (draft.pinned = value));
        }),
        motifComponent("text", (node) => node.setText(" Pin")),
      );
    });
    const due = motifComponent("input", (input) => {
      input.attr.add({ type: "date" });
      input.bindings.model(() => draft.due, (value) => (draft.due = value));
    });
    const submit = motifComponent("button", (button) => {
      button.attr.add({ type: "submit", disabled: () => !draft.title.trim() });
      button.controls.add(motifComponent("text", (node) => node.setText("Save")));
    });
    form.controls.add(title, body, pinned, due, submit);
  });
}
import { motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteForm = () => motifOptions({
  el: "form",
  data: reactive({ title: "", body: "", pinned: false, due: "" }),

  get notes() {
    return this.getService(NotesService);
  },

  save() {
    const note = this.notes.create();
    note.title = this.data.title;
    note.body = this.data.body;
    note.pinned = this.data.pinned;
    this.data.title = "";
    this.data.body = "";
    this.data.due = "";
  },

  onConfigured() {
    this.class.add("note-form");
    this.motif.on("submit:prevent", () => this.save());
  },

  view() {
    return (
      <>
        <input type="text" placeholder="Title" x-model={() => this.data.title} />
        <textarea placeholder="Body" x-model={() => this.data.body} />
        <label><input type="checkbox" x-model={() => this.data.pinned} /> Pin</label>
        <input type="date" x-model={() => this.data.due} />
        <button type="submit" disabled={() => !this.data.title.trim()}>Save</button>
      </>
    );
  },
});
import { motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteForm = () => motifOptions({
  el: "form",
  data: reactive({ title: "", body: "", pinned: false, due: "" }),

  get notes() {
    return this.getService(NotesService);
  },

  save() {
    const note = this.notes.create();
    note.title = this.data.title;
    note.body = this.data.body;
    note.pinned = this.data.pinned;
    this.data.title = "";
    this.data.body = "";
    this.data.due = "";
  },

  onConfigured() {
    this.class.add("note-form");
    this.motif.on("submit:prevent", () => this.save());
  },

  view() {
    const title = motifComponent("input", (input) => {
      input.attr.add({ type: "text", placeholder: "Title" });
      input.bindings.model(() => this.data.title, (value) => (this.data.title = value));
    });
    const body = motifComponent("textarea", (area) => {
      area.attr.add({ placeholder: "Body" });
      area.bindings.model(() => this.data.body, (value) => (this.data.body = value));
    });
    const pinned = motifComponent("label", (label) => {
      label.controls.add(
        motifComponent("input", (box) => {
          box.attr.add({ type: "checkbox" });
          box.bindings.model(() => this.data.pinned, (value) => (this.data.pinned = value));
        }),
        motifComponent("text", (node) => node.setText(" Pin")),
      );
    });
    const due = motifComponent("input", (input) => {
      input.attr.add({ type: "date" });
      input.bindings.model(() => this.data.due, (value) => (this.data.due = value));
    });
    const submit = motifComponent("button", (button) => {
      button.attr.add({ type: "submit", disabled: () => !this.data.title.trim() });
      button.controls.add(motifComponent("text", (node) => node.setText("Save")));
    });
    return [title, body, pinned, due, submit];
  },
});

onsubmit={(sender, e) => { e.preventDefault(); save(); }} does the same job with a block body. When element methods are written as JSX props, no attribute is written and the method is called: <form requestSubmit={() => draft.send}>, <input focus={() => draft.editing} /> (false → blur()), <input setSelectionRange={[0, 5]} />. reportValidity, checkValidity, select, reset and submit are in this group as well; for the full list and the value rules, see Element methods.

Submitting with a function action

When a function is given to the action attribute instead of a URL, that function is called when the form is submitted and the page does not navigate. The search box is a form: on submit, search receives the query, and the "Clear" button empties the query with its own formAction:

<form class="search" action={(data) => this.search(String(data.get("q") ?? ""))}>
  <input type="search" name="q" placeholder="Search notes" />
  <button type="submit">Search</button>
  <button type="submit" formAction={() => this.search("")}>Clear</button>
</form>
<form class="search" action={(data) => search(String(data.get("q") ?? ""))}>
  <input type="search" name="q" placeholder="Search notes" />
  <button type="submit">Search</button>
  <button type="submit" formAction={() => search("")}>Clear</button>
</form>
motifComponent("form", (form: ComponentBase) => {
  form.class.add("search");
  form.attr.add({ action: () => (data: FormData) => this.search(String(data.get("q") ?? "")) });
  const field = motifComponent("input", (input: ComponentBase) => input.attr.add({ type: "search", name: "q", placeholder: "Search notes" }));
  const run = motifComponent("button", (button: ComponentBase) => {
    button.attr.add({ type: "submit" });
    button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Search")));
  });
  const clear = motifComponent("button", (button: ComponentBase) => {
    button.attr.add({ type: "submit", formaction: () => () => this.search("") });
    button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Clear")));
  });
  form.controls.add(field, run, clear);
});
motifComponent("form", (form: ComponentBase) => {
  form.class.add("search");
  form.attr.add({ action: () => (data: FormData) => search(String(data.get("q") ?? "")) });
  const field = motifComponent("input", (input: ComponentBase) => input.attr.add({ type: "search", name: "q", placeholder: "Search notes" }));
  const run = motifComponent("button", (button: ComponentBase) => {
    button.attr.add({ type: "submit" });
    button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Search")));
  });
  const clear = motifComponent("button", (button: ComponentBase) => {
    button.attr.add({ type: "submit", formaction: () => () => search("") });
    button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Clear")));
  });
  form.controls.add(field, run, clear);
});
<form class="search" action={(data) => this.search(String(data.get("q") ?? ""))}>
  <input type="search" name="q" placeholder="Search notes" />
  <button type="submit">Search</button>
  <button type="submit" formAction={() => this.search("")}>Clear</button>
</form>
<form class="search" action={(data) => search(String(data.get("q") ?? ""))}>
  <input type="search" name="q" placeholder="Search notes" />
  <button type="submit">Search</button>
  <button type="submit" formAction={() => search("")}>Clear</button>
</form>
motifComponent("form", (form) => {
  form.class.add("search");
  form.attr.add({ action: () => (data) => this.search(String(data.get("q") ?? "")) });
  const field = motifComponent("input", (input) => input.attr.add({ type: "search", name: "q", placeholder: "Search notes" }));
  const run = motifComponent("button", (button) => {
    button.attr.add({ type: "submit" });
    button.controls.add(motifComponent("text", (node) => node.setText("Search")));
  });
  const clear = motifComponent("button", (button) => {
    button.attr.add({ type: "submit", formaction: () => () => this.search("") });
    button.controls.add(motifComponent("text", (node) => node.setText("Clear")));
  });
  form.controls.add(field, run, clear);
});
motifComponent("form", (form) => {
  form.class.add("search");
  form.attr.add({ action: () => (data) => search(String(data.get("q") ?? "")) });
  const field = motifComponent("input", (input) => input.attr.add({ type: "search", name: "q", placeholder: "Search notes" }));
  const run = motifComponent("button", (button) => {
    button.attr.add({ type: "submit" });
    button.controls.add(motifComponent("text", (node) => node.setText("Search")));
  });
  const clear = motifComponent("button", (button) => {
    button.attr.add({ type: "submit", formaction: () => () => search("") });
    button.controls.add(motifComponent("text", (node) => node.setText("Clear")));
  });
  form.controls.add(field, run, clear);
});

In the function component, search and refresh are local functions inside the component function, and state is the local state set up with reactive.

As with every attribute, the value is read inside a getter. If the result is a function, no attribute is written and the function becomes the submit handler; if it is a string, it is written as the URL and stays live (action="/search", action={state.url}). That is why, without JSX, the value is given with a getter: attr.add({ action: () => search }) attaches the handler, while attr.add({ action: search }) treats search as the getter and writes its result. motif-explain shows this attribute as attr.action.

On submit, the form's own onsubmit handlers run first; if one of them called preventDefault() (onsubmit:prevent included), the function is not called. Otherwise the framework calls preventDefault(), builds new FormData(form, submitter) and calls the function by the rules of event handlers: one parameter (data), two parameters (sender, data); sender is the component that carries the attribute, the form for action and the button for formaction. The name/value pair of the submitting button is inside data. If the submitter's formaction is a function, it takes precedence over the form's action; if it is a string, the browser does the submitting. form.requestSubmit() runs the function; form.submit() produces no event, so it does not. If the function throws, or the promise it returns is rejected, the error is reported with MJX123 (see Error Handling). The form is not reset after submission; the function empties any field that needs emptying itself.

The function can be async. The waiting state is kept in the operation itself: refresh pulls the notes from SyncService (src/services/sync.ts on the Lifecycle page), raises a flag at the start and lowers it at the end, and the button is bound to that flag:

async refresh() {
  this.state.syncing = true;
  try {
    this.notes.state.items = await this.sync.pull();
  } finally {
    this.state.syncing = false;
  }
}
const refresh = async () => {
  state.syncing = true;
  try {
    notes.state.items = await sync.pull();
  } finally {
    state.syncing = false;
  }
};
async refresh() {
  this.state.syncing = true;
  try {
    this.notes.state.items = await this.sync.pull();
  } finally {
    this.state.syncing = false;
  }
}
const refresh = async () => {
  state.syncing = true;
  try {
    notes.state.items = await sync.pull();
  } finally {
    state.syncing = false;
  }
};

In the function component, state is the local state the component sets up with reactive, and notes and sync are the services obtained with FromService.

<form action={() => this.refresh()}>
  <button type="submit" disabled={() => this.state.syncing}>Refresh from server</button>
</form>
<form action={() => refresh()}>
  <button type="submit" disabled={() => state.syncing}>Refresh from server</button>
</form>
motifComponent("form", (form: ComponentBase) => {
  form.attr.add({ action: () => () => this.refresh() });
  form.controls.add(motifComponent("button", (button: ComponentBase) => {
    button.attr.add({ type: "submit", disabled: () => this.state.syncing });
    button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Refresh from server")));
  }));
});
motifComponent("form", (form: ComponentBase) => {
  form.attr.add({ action: () => () => refresh() });
  form.controls.add(motifComponent("button", (button: ComponentBase) => {
    button.attr.add({ type: "submit", disabled: () => state.syncing });
    button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Refresh from server")));
  }));
});
<form action={() => this.refresh()}>
  <button type="submit" disabled={() => this.state.syncing}>Refresh from server</button>
</form>
<form action={() => refresh()}>
  <button type="submit" disabled={() => state.syncing}>Refresh from server</button>
</form>
motifComponent("form", (form) => {
  form.attr.add({ action: () => () => this.refresh() });
  form.controls.add(motifComponent("button", (button) => {
    button.attr.add({ type: "submit", disabled: () => this.state.syncing });
    button.controls.add(motifComponent("text", (node) => node.setText("Refresh from server")));
  }));
});
motifComponent("form", (form) => {
  form.attr.add({ action: () => () => refresh() });
  form.controls.add(motifComponent("button", (button) => {
    button.attr.add({ type: "submit", disabled: () => state.syncing });
    button.controls.add(motifComponent("text", (node) => node.setText("Refresh from server")));
  }));
});

A validation pattern

MotifJS does not impose a special validation API; you build it with reactive fields. A derived get field holds the rule, and x-wait holds the error text back until the rule is satisfied:

const draft = reactive({
  title: "",
  get valid() { return draft.title.trim().length >= 3; },
});
const draft = reactive({
  title: "",
  get valid() { return draft.title.trim().length >= 3; },
});
<>
  <input type="text" value={() => draft.title} oninput={(e) => (draft.title = (e.target as HTMLInputElement).value)} />
  <small class="error" x-wait={() => draft.valid}>Title must be at least 3 characters</small>
  <button type="submit" disabled={() => !draft.valid}>Save</button>
</>
const title = motifComponent("input", (field: ComponentBase) => {
  field.attr.add({ type: "text" });
  field.bindings.add("value", () => draft.title);
  field.motif.on("input", (_sender: ComponentBase, e: Event) => (draft.title = (e.target as HTMLInputElement).value));
});
const error = motifComponent("small", {
  preconfig: (error: ComponentBase) => error.bindings.wait(() => draft.valid),
  initializeComponent: (error: ComponentBase) => {
    error.class.add("error");
    error.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Title must be at least 3 characters")));
  },
});
const save = motifComponent("button", (button: ComponentBase) => {
  button.attr.add({ type: "submit", disabled: () => !draft.valid });
  button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Save")));
});
<>
  <input type="text" value={() => draft.title} oninput={(e) => (draft.title = e.target.value)} />
  <small class="error" x-wait={() => draft.valid}>Title must be at least 3 characters</small>
  <button type="submit" disabled={() => !draft.valid}>Save</button>
</>
const title = motifComponent("input", (field) => {
  field.attr.add({ type: "text" });
  field.bindings.add("value", () => draft.title);
  field.motif.on("input", (_sender, e) => (draft.title = e.target.value));
});
const error = motifComponent("small", {
  preconfig: (error) => error.bindings.wait(() => draft.valid),
  initializeComponent: (error) => {
    error.class.add("error");
    error.controls.add(motifComponent("text", (node) => node.setText("Title must be at least 3 characters")));
  },
});
const save = motifComponent("button", (button) => {
  button.attr.add({ type: "submit", disabled: () => !draft.valid });
  button.controls.add(motifComponent("text", (node) => node.setText("Save")));
});

Comparing the methods

Method When
One-way (value + oninput) When you will run extra logic on change (conversion, validation, a side effect).
bindings.model / x-model When two-way sync is enough; the least code.
bindings.add("checked"/"value", ...) + an event When you want to pick the bound property by hand (one-way; writing back through an event).
onsubmit:prevent When the fields are bound to the model; the submission only triggers the operation.
action={fn} When the fields are not bound and you want their values in one go as FormData; different operations per button with formaction.

Next step

To learn about the lifecycle of components in detail, go on to Lifecycle.