Core concepts

Views without JSX

Writing by hand the motifComponent calls that JSX comes down to; call shapes, new Component, props keys, text nodes, preconfig, fragments for branches and lists, class components in files without JSX, reading the compiled output.

The compiler turns every JSX element into a motifComponent(tag, setup) call, and the tree is built with sender.controls.add(...). A project that does not use JSX (.ts / .js) writes the same calls by hand: the hand-written version of the code the compiler produces. The runtime, the bindings, the lifecycle and the rules are the same; only the way of writing changes. This page is for projects without JSX, for libraries that should not depend on the compiler, for views generated in code, and for reading compiled output. Which call each JSX form comes down to is given next to every example on the JSX and Templates page; here the calls themselves are explained.

The examples are slices of the version without JSX of the notes application in the starter template: the sidebar (MainLayout), the notes list (NotesLayout), the list row (NoteRow) and the editor (NotePage). Note, Folder and NotesService are the ones in the src/services/notes.ts file on the Components page. In TypeScript the parameter of a setup function is written as ComponentBase; motifComponent returns any, so you declare the types yourself (see Pitfalls).

Call shapes

JSX Written by hand
<div class="app">…</div> motifComponent("div", (div) => { div.class.add("app"); div.controls.add(…); })
Notes (plain text) motifComponent("text", (node) => node.setText("Notes")) — a text node
{note.title} node.bindings.add("textContent", note, "title") on a text node
{() => note.title.trim() \|\| "Untitled"} span.bindings.text(() => note.title.trim() \|\| "Untitled"); the compiler writes bindings.method(fn), which does the same job and also accepts a component result
type="button" aria-pressed={() => note.pinned} aria-current={() => (open() ? "page" : null)} button.attr.add({ type: "button", "aria-pressed": () => note.pinned, "aria-current": () => (open() ? "page" : null) }) — a function is a live getter, a plain value is written once, and false/null remove the attribute. On action and formaction, the function returned by the getter becomes the form's submit handler: form.attr.add({ action: () => (data: FormData) => … }) (see Submitting with a function action)
class={() => (note.pinned ? "note pinned" : "note")} li.class.add(() => (note.pinned ? "note pinned" : "note")); a string, a { pinned: () => note.pinned } object and arrays of these work too
onclick={() => …}, onsubmit:prevent={…} button.motif.on("click", () => …), form.motif.on("submit:prevent", fn); modifiers chain ("click:once:prevent"). The typed signature is (sender, e): if you need the event, (_sender: ComponentBase, e: Event) => …
<input x-model={() => state.query} /> field.bindings.model(() => state.query, (value: string) => (state.query = value))
<p x-display={() => cond}> motifComponent("p", { preconfig: (p) => p.bindings.display(() => cond), initializeComponent: (p) => { … } }); x-wait → bindings.wait (see preconfig)
{cond ? <A/> : <B/>} pane.bindings.ternary(() => cond, (frame) => frame.navigate(a), (frame) => frame.navigate(b))
{cond && <X/>} pane.bindings.when(() => cond, () => x)
{visible.map((note) => <NoteRow key={note.id} note={note} folder={folder} />)} a holder without an element: motifComponent({ initializeComponent: (holder) => holder.bindings.list(() => visible, (note) => motifComponent(NoteRow, { indexkey: () => note.id, note, folder })) })
<input ref={(c) => (search = c)} /> motifComponent("input", { ref: (c) => (search = c), initializeComponent: (c) => { … } }); the node is search.element
<RouterLink to="/notes" activeClass="active">Notes</RouterLink> motifComponent(RouterLink, { to: "/notes", activeClass: "active", childs: [motifComponent("text", (node) => node.setText("Notes"))] })
<RouterView class="outlet" /> motifComponent(RouterView, { class: "outlet" })
<a rel="router" href="/notes"> motifComponent("a", (a) => a.attr.add({ rel: "router", href: "/notes" }))
<NoteRow note={note} folder={folder} /> motifComponent(NoteRow, { note, folder }): a class is built with the props, a function part is called with the props
<svg viewBox="0 0 10 10"><path d="M0 0h10" /></svg> motifComponent("svg", { options: { isSvg: true }, initializeComponent: (svg) => { svg.attr.add({ viewBox: "0 0 10 10" }); svg.controls.add(motifComponent("path", { options: { isSvg: true }, initializeComponent: (p) => p.attr.add({ d: "M0 0h10" }) })); } }) — every SVG child carries isSvg too
onMounted={(c) => …} (on a tag) the onMounted key in the object form (and the same for the other hooks; the signature is (sender, e)), or c.motif.on("x:mounted", …) during setup

motifComponent(element, props?)

Timing: on a plain tag the configuration phase (preconfig, onConfig) ends inside the motifComponent call, while the component is being created; onConfigured, initializeComponent and onBuilt run when the component is added to a built parent (build()), and onMounted when it is attached to the document. In a class component the configuration phase is postponed to the start of build() (see Lifecycle).

The third argument seen in the compiler output (, 1) and the __childExpr and __isSvgElement keys are compiler markers; hand-written code does not write them and does not call motifCompiled / motifReport (see Reading the compiled output).

new Component(element, props?)

The constructor's first argument decides the component's element, and the second is the props object:

First argument Result
A tag name: new Component("li", props) A new element with that tag is created. "text" produces a text node. For an SVG element, write options: { isSvg: true } in the props.
A DOM node: new Component(node, props) The existing node becomes the component's element, and no new element is created. app.run("#app") wraps the #app element on the page this way.
A props object, or nothing: new Component({ … }) No element is created, and the component's root is a comment node. In a subclass that declares static elementTag, an element with that tag is created.
An array: new Component([title, body]) A component without an element. Each item in the array is turned into a component by the same rules and added as a child.
A class or a function: new Component(NoteRow, props) A class is built with props and a function is called with props; the new expression gives the returned component. The framework keys of the props are applied to the root the function returns, as with motifComponent (see the function part item above). If the function returns an Options object, the component is built from that object.
A ready component: new Component(row) The same component is returned; no new one is created.

How it differs from motifComponent:

On a plain Component, the keys in the props object that are not framework keys are written to the element: class, style, events (onclick …) and attributes; value, checked and selected are set up as bindings, and a function value without parameters becomes a live getter; on action and formaction, the function returned by the getter is the form's submit handler. In a subclass these keys stay in this.props, and only the common attributes fall through to the element. innerHTML and srcdoc are not written this way (MJX124); a javascript: address in an href, src, action or formaction value is blocked (MJX125).

motifFragment(props) is the same as new FragmentNode(props): while being built, it adds the components in the childs and nodes arrays of the props as children. FNComponent(view) turns a view function into a function component; when the returned function is called with props, it builds a component without an element, gives it the props and adds the component returned by view(props) as a child. That is the _mfc import in the compiler output; function components written in JSX are not wrapped in it.

Keys of the props object

MotifJS handles these keys itself; they are not written to the element as attributes.

Key What it does
initializeComponent The component's setup code. Runs once while the component is being built, before its children; its parameter is the component itself.
oninitializeComponent Runs right after initializeComponent (see Lifecycle).
preconfig Runs in the configuration phase, before onConfig (see preconfig).
onConfig, onConfigured, onInitializing, onInitialized, onBuilding, onBuilt, onMounted, onVisibilityChanged, onActivated, onDeactivated, onDisposing, onDisposed Lifecycle hooks: a function or an array of functions, with the signature (sender, e) (see Hooks and run order).
x-wait, x-display Registered as bindings.wait and bindings.display. Other x- keys such as x-mounted are attached to the hook of the same name.
childs Child components; nested arrays are flattened. A component without an element, and a class created without an element that writes no view(), add them as content; a class that writes view() places them itself. A component created by tag name does not add childs: new Component("ul", { childs }) gives an empty <ul>, and the children are added with controls.add inside initializeComponent.
ref A function called with the component itself once the component is created.
options isSvg (an SVG element), hideStrategy (see Hide strategy) and disableDisposal.
transition An enter and leave transition with CSS classes (see The transition prop).
onElementCreating A function that returns an element; the component uses it instead of the element from the first argument.
key, indexkey The identity of a list row; the compiler writes key from JSX as indexkey. It is not written to the element and is used only in the MJX202 warning, which reports the same identity arriving twice.

In a function component row, indexkey is written to the props object of the root the function returns. If the function builds its root with the same props object on every call (a module-level constant: motifComponent("li", ROW_PROPS)), every row reads the key written to that object last. Even if the keys are unique, an MJX202 warning arrives the first time the list updates; the rendering is not affected. Give the root's props as a new object on every call: motifComponent("li", { class: "row" }). In JSX this does not happen, because the compiler produces a new object on every call.

Text nodes

"text" is a real tag: motifComponent("text", (node: ComponentBase) => node.setText("…")) produces a text node, and text next to an element becomes one more child in the same controls.add call. A binding can be set up on a text node too: node.bindings.text(() => …) for live text through a getter, and node.bindings.add("textContent", note, "title") for a one-way binding to a single field. On an element whose only content is text, write span.setText("…") or span.bindings.text(fn) without creating a node. The runtime also turns strings and numbers given to controls.add into text nodes, but the TypeScript signature of controls.add takes components (ComponentBase[]), so in a TypeScript project write the "text" call.

The folder link in the sidebar shows the number of notes in the folder next to its name. The name is a text node and the number is a badge bound with bindings.text; both are given to RouterLink as childs:

private folderLink(folder: Folder, label: string) {
  const badge = motifComponent("span", (span: ComponentBase) => {
    span.class.add("badge");
    span.bindings.text(() => String(this.notes.count(folder)));
  });
  return motifComponent(RouterLink, {
    to: `/${folder}`,
    activeClass: "active",
    childs: [motifComponent("text", (node: ComponentBase) => node.setText(`${label} `)), badge],
  });
}
private folderLink(folder: Folder, label: string) {
  const badge = motifComponent("span", (span: ComponentBase) => {
    span.class.add("badge");
    span.bindings.text(() => String(notes.count(folder)));
  });
  return motifComponent(RouterLink, {
    to: `/${folder}`,
    activeClass: "active",
    childs: [motifComponent("text", (node: ComponentBase) => node.setText(`${label} `)), badge],
  });
}
folderLink(folder, label) {
  const badge = motifComponent("span", (span) => {
    span.class.add("badge");
    span.bindings.text(() => String(this.notes.count(folder)));
  });
  return motifComponent(RouterLink, {
    to: `/${folder}`,
    activeClass: "active",
    childs: [motifComponent("text", (node) => node.setText(`${label} `)), badge],
  });
}
folderLink(folder, label) {
  const badge = motifComponent("span", (span) => {
    span.class.add("badge");
    span.bindings.text(() => String(notes.count(folder)));
  });
  return motifComponent(RouterLink, {
    to: `/${folder}`,
    activeClass: "active",
    childs: [motifComponent("text", (node) => node.setText(`${label} `)), badge],
  });
}

In the function component, notes is the service obtained with FromService and state is the local state set up with reactive; visible(), folder(), confirmation, actions and remove are local functions inside the component function.

preconfig

preconfig: (c) => … runs while the component is being configured, before onConfig and build(); on a plain tag this is inside the motifComponent call. A bindings.display / bindings.wait registered there keeps an element that starts out hidden unbuilt: its subtree, initializeComponent, onBuilt and onMounted wait until the condition holds. That is what x-wait / x-display are in JSX. wait holds the component for as long as the function returns true, and display shows it while it is true. If the same binding is registered inside initializeComponent, the element is built first and hidden afterwards.

The pin mark on a list row is visible only on a note that is pinned and not archived; the empty list message in the list pane appears when no visible notes are left:

const pin = motifComponent("i", {
  preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned && !note.archived),
  initializeComponent: (i: ComponentBase) => {
    i.class.add("pin");
    i.attr.add({ "aria-label": "Pinned" });
  },
});
const pin = motifComponent("i", {
  preconfig: (i) => i.bindings.display(() => note.pinned && !note.archived),
  initializeComponent: (i) => {
    i.class.add("pin");
    i.attr.add({ "aria-label": "Pinned" });
  },
});
const empty = motifComponent("p", {
  preconfig: (p: ComponentBase) => p.bindings.display(() => this.visible.length === 0),
  initializeComponent: (p: ComponentBase) => {
    p.class.add("list__empty");
    p.bindings.text(() => (this.state.query.trim() ? "No notes match your search." : FOLDERS[this.folder].empty));
  },
});
const empty = motifComponent("p", {
  preconfig: (p: ComponentBase) => p.bindings.display(() => visible().length === 0),
  initializeComponent: (p: ComponentBase) => {
    p.class.add("list__empty");
    p.bindings.text(() => (state.query.trim() ? "No notes match your search." : FOLDERS[folder()].empty));
  },
});
const empty = motifComponent("p", {
  preconfig: (p) => p.bindings.display(() => this.visible.length === 0),
  initializeComponent: (p) => {
    p.class.add("list__empty");
    p.bindings.text(() => (this.state.query.trim() ? "No notes match your search." : FOLDERS[this.folder].empty));
  },
});
const empty = motifComponent("p", {
  preconfig: (p) => p.bindings.display(() => visible().length === 0),
  initializeComponent: (p) => {
    p.class.add("list__empty");
    p.bindings.text(() => (state.query.trim() ? "No notes match your search." : FOLDERS[folder()].empty));
  },
});

Neither is built when it starts out hidden; initializeComponent runs the first time it is shown. When the condition turns false again, the element stays built and is hidden. wait is the reverse of the same binding: it holds the component for as long as the function returns true and shows it when it turns false. It is given to a component with the same key, and it is the same for class and function components: motifComponent(NoteRow, { note, folder, preconfig: (row: ComponentBase) => row.bindings.display(() => !note.archived) }). The difference between display and wait, and the hide strategy, are on the Conditional Rendering and Lists and Lifecycle pages.

Fragments for branches and lists

If a branch has a single root, give a component; if it has several roots, a motifFragment; if it is only text, a "text" component; frame.flush() empties the spot. The editor's toolbar asks for confirmation before deleting a note. The confirmation is a question and two buttons, and since it has several roots it is given with motifFragment; actions(note) returns the pin, archive and delete buttons the same way:

private toolbar(note: Note) {
  return motifComponent("div", (bar: ComponentBase) => {
    bar.class.add("toolbar");
    bar.bindings.ternary(
      () => this.state.confirming,
      (frame) => frame.navigate(this.confirmation(note)),
      (frame) => frame.navigate(this.actions(note)),
    );
  });
}

private confirmation(note: Note) {
  return motifFragment({
    initializeComponent: (tools: ComponentBase) => {
      const question = motifComponent("span", (span: ComponentBase) => {
        span.class.add("confirm");
        span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete this note permanently?")));
      });
      const remove = motifComponent("button", (button: ComponentBase) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm danger accent");
        button.motif.on("click", () => this.remove(note));
        button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete")));
      });
      const cancel = motifComponent("button", (button: ComponentBase) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm ghost");
        button.motif.on("click", () => (this.state.confirming = false));
        button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Cancel")));
      });
      tools.controls.add(question, remove, cancel);
    },
  });
}
private toolbar(note: Note) {
  return motifComponent("div", (bar: ComponentBase) => {
    bar.class.add("toolbar");
    bar.bindings.ternary(
      () => state.confirming,
      (frame) => frame.navigate(confirmation(note)),
      (frame) => frame.navigate(actions(note)),
    );
  });
}

private confirmation(note: Note) {
  return motifFragment({
    initializeComponent: (tools: ComponentBase) => {
      const question = motifComponent("span", (span: ComponentBase) => {
        span.class.add("confirm");
        span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete this note permanently?")));
      });
      const removeButton = motifComponent("button", (button: ComponentBase) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm danger accent");
        button.motif.on("click", () => remove(note));
        button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete")));
      });
      const cancel = motifComponent("button", (button: ComponentBase) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm ghost");
        button.motif.on("click", () => (state.confirming = false));
        button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Cancel")));
      });
      tools.controls.add(question, removeButton, cancel);
    },
  });
}
toolbar(note) {
  return motifComponent("div", (bar) => {
    bar.class.add("toolbar");
    bar.bindings.ternary(
      () => this.state.confirming,
      (frame) => frame.navigate(this.confirmation(note)),
      (frame) => frame.navigate(this.actions(note)),
    );
  });
}

confirmation(note) {
  return motifFragment({
    initializeComponent: (tools) => {
      const question = motifComponent("span", (span) => {
        span.class.add("confirm");
        span.controls.add(motifComponent("text", (node) => node.setText("Delete this note permanently?")));
      });
      const remove = motifComponent("button", (button) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm danger accent");
        button.motif.on("click", () => this.remove(note));
        button.controls.add(motifComponent("text", (node) => node.setText("Delete")));
      });
      const cancel = motifComponent("button", (button) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm ghost");
        button.motif.on("click", () => (this.state.confirming = false));
        button.controls.add(motifComponent("text", (node) => node.setText("Cancel")));
      });
      tools.controls.add(question, remove, cancel);
    },
  });
}
toolbar(note) {
  return motifComponent("div", (bar) => {
    bar.class.add("toolbar");
    bar.bindings.ternary(
      () => state.confirming,
      (frame) => frame.navigate(confirmation(note)),
      (frame) => frame.navigate(actions(note)),
    );
  });
}

confirmation(note) {
  return motifFragment({
    initializeComponent: (tools) => {
      const question = motifComponent("span", (span) => {
        span.class.add("confirm");
        span.controls.add(motifComponent("text", (node) => node.setText("Delete this note permanently?")));
      });
      const removeButton = motifComponent("button", (button) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm danger accent");
        button.motif.on("click", () => remove(note));
        button.controls.add(motifComponent("text", (node) => node.setText("Delete")));
      });
      const cancel = motifComponent("button", (button) => {
        button.attr.add({ type: "button" });
        button.class.add("button--sm ghost");
        button.motif.on("click", () => (state.confirming = false));
        button.controls.add(motifComponent("text", (node) => node.setText("Cancel")));
      });
      tools.controls.add(question, removeButton, cancel);
    },
  });
}

For a list that changes, use a holder without an element and bindings.list; the row component declares its identity with indexkey (that is how the compiler writes key). When the order changes the nodes are moved, not rebuilt. For fixed options a plain for loop is enough. The list pane lists the visible notes like this:

const list = motifComponent({
  initializeComponent: (holder: ComponentBase) => holder.bindings.list(
    () => this.visible,
    (note: Note) => motifComponent(NoteRow, { indexkey: () => note.id, note, folder: this.folder }),
  ),
});
const list = motifComponent({
  initializeComponent: (holder: ComponentBase) => holder.bindings.list(
    () => visible(),
    (note: Note) => motifComponent(NoteRow, { indexkey: () => note.id, note, folder: folder() }),
  ),
});
const list = motifComponent({
  initializeComponent: (holder) => holder.bindings.list(
    () => this.visible,
    (note) => motifComponent(NoteRow, { indexkey: () => note.id, note, folder: this.folder }),
  ),
});
const list = motifComponent({
  initializeComponent: (holder) => holder.bindings.list(
    () => visible(),
    (note) => motifComponent(NoteRow, { indexkey: () => note.id, note, folder: folder() }),
  ),
});

The behaviour of when, ternary, list and switchCase is described on the Conditional Rendering and Lists page.

Class components in files without JSX

The Vite plugin processes .ts, .mts and .cts files too: it adds static elementTag = "div" to a class that writes extends Component<HTMLDivElement>, and the component gets an element of its own. In a .js file no generic is written, so static elementTag = "div" is written by hand. In a file the plugin does not process (a library compiled only with tsc, a package in node_modules), write static elementTag = "div" or call super("div") as well. A class that declares no element type is a fragment: this.element is a comment marker; this.class and this.attr reach no element at all, this.motif.on listens on the top-level elements of the content, and x-display and this.motif.hide() hide the children. Returning a wrapper from view() does not give the component an element: the wrapper is the first child of the content, so write classes and attributes on it (see Root element). The layout of the notes list is a class without an element; view() returns the content:

import { Component, ComponentBase, RouterView, motifComponent, reactive, useNavigation } from "@motifx/core";
import { NotesService, type Folder } from "../services/notes";

export default class NotesLayout extends Component {
  notes = this.getService(NotesService)!;
  nav = useNavigation();
  state = reactive({ query: "" });

  get folder(): Folder {
    return this.nav.meta.folder as Folder;
  }

  get visible() {
    return this.notes.list(this.folder, this.state.query);
  }

  override view() {
    return motifComponent("div", (root: ComponentBase) => {
      root.class.add("notes");
      const detail = motifComponent("main", (main: ComponentBase) => {
        main.class.add("detail");
        main.controls.add(motifComponent(RouterView, { class: "outlet" }));
      });
      root.controls.add(this.listPane(), detail);
    });
  }

  private listPane() {
    return motifComponent("section", (list: ComponentBase) => {
      list.class.add("list");
      list.attr.add({ "aria-label": "Note list" });
    });
  }
}
import { Component, RouterView, motifComponent, reactive, useNavigation } from "@motifx/core";
import { NotesService } from "../services/notes";

export default class NotesLayout extends Component {
  notes = this.getService(NotesService);
  nav = useNavigation();
  state = reactive({ query: "" });

  get folder() {
    return this.nav.meta.folder;
  }

  get visible() {
    return this.notes.list(this.folder, this.state.query);
  }

  view() {
    return motifComponent("div", (root) => {
      root.class.add("notes");
      const detail = motifComponent("main", (main) => {
        main.class.add("detail");
        main.controls.add(motifComponent(RouterView, { class: "outlet" }));
      });
      root.controls.add(this.listPane(), detail);
    });
  }

  listPane() {
    return motifComponent("section", (list) => {
      list.class.add("list");
      list.attr.add({ "aria-label": "Note list" });
    });
  }
}

NotesLayout has no element of its own; <div class="notes"> is the first child of the content. The form with an element of its own is the PinnedNotes class below: its element is <aside class="pinned">, and view() returns only the children. A class's view() returns a single component (of type ComponentBase); for several roots write motifFragment. The view() of an Options API component can return an array as well.

The reading order stays as it is in JSX: the children as consts, a single controls.add at the end, and private methods that each return a component for the parts. PinnedNotes, added to the sidebar, lists the pinned notes, and each row has a button that unpins it; it is the same component in all three forms:

import { Component, ComponentBase, motifComponent, motifFragment } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export class PinnedNotes extends Component<HTMLElement> {
  static override elementTag = "aside";
  notes = this.getService(NotesService)!;

  get pinned() {
    return this.notes.list("pinned");
  }

  override onConfigured() {
    this.class.add("pinned");
  }

  override view() {
    const heading = motifComponent("h3", (h3: ComponentBase) => h3.bindings.text(() => `Pinned (${this.pinned.length})`));
    const rows = motifComponent("ul", (ul: ComponentBase) => {
      ul.controls.add(motifComponent({
        initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => this.pinned, (note: Note) => this.row(note)),
      }));
    });
    const empty = motifComponent("p", {
      preconfig: (p: ComponentBase) => p.bindings.display(() => this.pinned.length === 0),
      initializeComponent: (p: ComponentBase) => {
        p.class.add("hint");
        p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("No pinned notes.")));
      },
    });
    return motifFragment({ initializeComponent: (content: ComponentBase) => content.controls.add(heading, rows, empty) });
  }

  private row(note: Note) {
    return motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li: ComponentBase) => {
        const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
        const unpin = motifComponent("button", (button: ComponentBase) => {
          button.attr.add({ type: "button", title: "Unpin" });
          button.motif.on("click", () => this.notes.togglePin(note));
          button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("×")));
        });
        li.controls.add(title, unpin);
      },
    });
  }
}
import { ComponentBase, FromService, motifComponent } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export function PinnedNotes() {
  const notes = FromService(NotesService)!;
  const pinned = () => notes.list("pinned");

  const row = (note: Note) =>
    motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li: ComponentBase) => {
        const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
        const unpin = motifComponent("button", (button: ComponentBase) => {
          button.attr.add({ type: "button", title: "Unpin" });
          button.motif.on("click", () => notes.togglePin(note));
          button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("×")));
        });
        li.controls.add(title, unpin);
      },
    });

  return motifComponent("aside", (aside: ComponentBase) => {
    aside.class.add("pinned");
    const heading = motifComponent("h3", (h3: ComponentBase) => h3.bindings.text(() => `Pinned (${pinned().length})`));
    const rows = motifComponent("ul", (ul: ComponentBase) => {
      ul.controls.add(motifComponent({
        initializeComponent: (holder: ComponentBase) => holder.bindings.list(pinned, (note: Note) => row(note)),
      }));
    });
    const empty = motifComponent("p", {
      preconfig: (p: ComponentBase) => p.bindings.display(() => pinned().length === 0),
      initializeComponent: (p: ComponentBase) => {
        p.class.add("hint");
        p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("No pinned notes.")));
      },
    });
    aside.controls.add(heading, rows, empty);
  });
}
import { ComponentBase, FromService, motifComponent, motifOptions } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export const PinnedNotes = () => motifOptions({
  el: "aside",

  view() {
    const notes = FromService(NotesService)!;
    const pinned = () => notes.list("pinned");
    const row = (note: Note) =>
      motifComponent("li", {
        indexkey: () => note.id,
        initializeComponent: (li: ComponentBase) => {
          const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
          const unpin = motifComponent("button", (button: ComponentBase) => {
            button.attr.add({ type: "button", title: "Unpin" });
            button.motif.on("click", () => notes.togglePin(note));
            button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("×")));
          });
          li.controls.add(title, unpin);
        },
      });

    this.class.add("pinned");
    const heading = motifComponent("h3", (h3: ComponentBase) => h3.bindings.text(() => `Pinned (${pinned().length})`));
    const rows = motifComponent("ul", (ul: ComponentBase) => {
      ul.controls.add(motifComponent({
        initializeComponent: (holder: ComponentBase) => holder.bindings.list(pinned, (note: Note) => row(note)),
      }));
    });
    const empty = motifComponent("p", {
      preconfig: (p: ComponentBase) => p.bindings.display(() => pinned().length === 0),
      initializeComponent: (p: ComponentBase) => {
        p.class.add("hint");
        p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("No pinned notes.")));
      },
    });
    return [heading, rows, empty];
  },
});
import { Component, motifComponent, motifFragment } from "@motifx/core";
import { NotesService } from "../services/notes";

export class PinnedNotes extends Component {
  static elementTag = "aside";
  notes = this.getService(NotesService);

  get pinned() {
    return this.notes.list("pinned");
  }

  onConfigured() {
    this.class.add("pinned");
  }

  view() {
    const heading = motifComponent("h3", (h3) => h3.bindings.text(() => `Pinned (${this.pinned.length})`));
    const rows = motifComponent("ul", (ul) => {
      ul.controls.add(motifComponent({
        initializeComponent: (holder) => holder.bindings.list(() => this.pinned, (note) => this.row(note)),
      }));
    });
    const empty = motifComponent("p", {
      preconfig: (p) => p.bindings.display(() => this.pinned.length === 0),
      initializeComponent: (p) => {
        p.class.add("hint");
        p.controls.add(motifComponent("text", (node) => node.setText("No pinned notes.")));
      },
    });
    return motifFragment({ initializeComponent: (content) => content.controls.add(heading, rows, empty) });
  }

  row(note) {
    return motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li) => {
        const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
        const unpin = motifComponent("button", (button) => {
          button.attr.add({ type: "button", title: "Unpin" });
          button.motif.on("click", () => this.notes.togglePin(note));
          button.controls.add(motifComponent("text", (node) => node.setText("×")));
        });
        li.controls.add(title, unpin);
      },
    });
  }
}
import { FromService, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";

export function PinnedNotes() {
  const notes = FromService(NotesService);
  const pinned = () => notes.list("pinned");

  const row = (note) =>
    motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li) => {
        const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
        const unpin = motifComponent("button", (button) => {
          button.attr.add({ type: "button", title: "Unpin" });
          button.motif.on("click", () => notes.togglePin(note));
          button.controls.add(motifComponent("text", (node) => node.setText("×")));
        });
        li.controls.add(title, unpin);
      },
    });

  return motifComponent("aside", (aside) => {
    aside.class.add("pinned");
    const heading = motifComponent("h3", (h3) => h3.bindings.text(() => `Pinned (${pinned().length})`));
    const rows = motifComponent("ul", (ul) => {
      ul.controls.add(motifComponent({
        initializeComponent: (holder) => holder.bindings.list(pinned, (note) => row(note)),
      }));
    });
    const empty = motifComponent("p", {
      preconfig: (p) => p.bindings.display(() => pinned().length === 0),
      initializeComponent: (p) => {
        p.class.add("hint");
        p.controls.add(motifComponent("text", (node) => node.setText("No pinned notes.")));
      },
    });
    aside.controls.add(heading, rows, empty);
  });
}
import { FromService, motifComponent, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const PinnedNotes = () => motifOptions({
  el: "aside",

  view() {
    const notes = FromService(NotesService);
    const pinned = () => notes.list("pinned");
    const row = (note) =>
      motifComponent("li", {
        indexkey: () => note.id,
        initializeComponent: (li) => {
          const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
          const unpin = motifComponent("button", (button) => {
            button.attr.add({ type: "button", title: "Unpin" });
            button.motif.on("click", () => notes.togglePin(note));
            button.controls.add(motifComponent("text", (node) => node.setText("×")));
          });
          li.controls.add(title, unpin);
        },
      });

    this.class.add("pinned");
    const heading = motifComponent("h3", (h3) => h3.bindings.text(() => `Pinned (${pinned().length})`));
    const rows = motifComponent("ul", (ul) => {
      ul.controls.add(motifComponent({
        initializeComponent: (holder) => holder.bindings.list(pinned, (note) => row(note)),
      }));
    });
    const empty = motifComponent("p", {
      preconfig: (p) => p.bindings.display(() => pinned().length === 0),
      initializeComponent: (p) => {
        p.class.add("hint");
        p.controls.add(motifComponent("text", (node) => node.setText("No pinned notes.")));
      },
    });
    return [heading, rows, empty];
  },
});

The component is built the way it would be written as a tag: motifComponent(PinnedNotes); in all three forms the common attributes fall through to the root (motifComponent(PinnedNotes, { class: "sidebar__pinned" })). In a file without JSX, @Injectable and the other decorators, DI, the router, useNavigation(), transitions and Virtualization work just the same; an override that does not reach super (MJX015) is reported during the Vite plugin's build, while motif-lint only looks at JSX files.

Reading the compiled output

npx motif-explain file.tsx prints which call an expression comes down to (see What did this expression compile to?). For the whole file, use the Compiler class in the @motifx/compiler package: new Compiler().motifCompile(code, "NoteRow.tsx")?.code returns the compiled code. This JSX:

<li>
  <i class="pin" x-display={() => note.pinned} />
  <span>{note.title}</span>
</li>
<li>
  <i class="pin" x-display={() => note.pinned} />
  <span>{note.title}</span>
</li>

compiles to this code:

import { motifComponent as _mc, FNComponent as _mfc, motifCompiled as _mv } from "@motifx/core";
/*#__PURE__*/_mv(1);
_mc("li", {
  initializeComponent: sender => {
    const _comp2 = _mc("i", {
      preconfig: sender => {
        sender.bindings.display(() => note.pinned);
      },
      initializeComponent: sender => {
        sender.class.add("pin");
      }
    });
    sender.controls.add(_comp2);
    const _comp3 = _mc("span", {
      initializeComponent: sender => {
        const _comp = _mc("text", {
          initializeComponent: sender => {
            sender.bindings.add("textContent", note, "title");
          },
          __childExpr: () => note.title
        });
        sender.controls.add(_comp);
      }
    }, 1);
    sender.controls.add(_comp3);
  }
}, 1);

The hand-written version:

motifComponent("li", (li: ComponentBase) => {
  const pin = motifComponent("i", {
    preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned),
    initializeComponent: (i: ComponentBase) => i.class.add("pin"),
  });
  const title = motifComponent("span", (span: ComponentBase) => {
    span.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title")));
  });
  li.controls.add(pin, title);
});
motifComponent("li", (li) => {
  const pin = motifComponent("i", {
    preconfig: (i) => i.bindings.display(() => note.pinned),
    initializeComponent: (i) => i.class.add("pin"),
  });
  const title = motifComponent("span", (span) => {
    span.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title")));
  });
  li.controls.add(pin, title);
});

A key for reading the output:

In the output When writing by hand
_mc( motifComponent(
_mf( motifFragment(
_mfc FNComponent; the compiler's import, not used by hand-written code
_mv(1) motifCompiled; the contract mark between the compiler and the runtime, absent from hand-written code
try { … } catch (__e) { _mr(__e); } motifReport; this is how the compiler reports the error of a child expression. A hand-written getter does not need try/catch: the runtime reports the error with MJX208
preconfig: sender => { sender.bindings.display(…) } x-display / x-wait; written the same way
indexkey: () => n.id key
__isSvgElement: true options: { isSvg: true }
__childExpr: () => note.title, childs: [], the third argument , 1 compiler markers; drop them
bindings.method(fn) bindings.text(fn), or method as it is
sender any parameter name you like ((li: ComponentBase))

Pitfalls of hand-written views

  1. An accidental fragment. A class that declares no element type is a fragment, and so is a Component<HTMLDivElement> class in a file the Vite plugin does not process. Write static elementTag or call super("div"). Returning a wrapper from view() does not give the component an element.
  2. An error inside initializeComponent is swallowed. If the setup function throws, the element stays empty; the only trace is the entry that lands in console.error with MJX122. A common cause: a const that calls a function with the same name in its own setup. Setup runs in build(), so at that moment the name holds the component: in const title = motifComponent("p", (p: ComponentBase) => p.controls.add(title("…"))), title("…") tries to call the component. motifComponent returns any, so type checking does not catch it; check the rendered text in a test, or watch console.error.
  3. display / wait in the wrong hook. If registered inside initializeComponent, the hidden element is built once; register it inside preconfig.
  4. No compiler, no diagnostics. MJX001–MJX005 and MJX016 are only for JSX; there is no warning for a list without indexkey or for a plain value that should have been a getter. A plain value is a snapshot, as in JSX: for CountBadge, motifComponent(CountBadge, { count: notes.count("pinned") }) reads the count once; to keep it live, write count: () => notes.count("pinned").
  5. The types are yours. You write the setup parameter ((c: ComponentBase)), the ref field (search!: Component<HTMLInputElement>) and the motif.on handler ((_sender: ComponentBase, e: KeyboardEvent)) yourself.
  6. controls.add is immediate, so there is nothing to wait for; reactive updates, on the other hand, are applied on a microtask, as in JSX (await Promise.resolve() in a test).

Next step