Core concepts

Conditional Rendering and Lists

x-wait/x-display, && and the ternary operator, .map lists, key and item identity, switch and the binding API; with and without JSX.

In MotifJS the primary way to render conditionally is the x-wait directive, and lists are written with .map inside JSX. @motifx/compiler turns these into reactive bindings: bindings.wait, bindings.display, bindings.when, bindings.ternary, bindings.list, bindings.switchCase, bindings.method. Next to every JSX form, this page shows its counterpart without JSX, that is, the binding call underneath it.

The examples are slices of the notes application in the starter template: the notes list (NotesLayout), the editor (NotePage) and the sidebar (MainLayout). Note and NotesService are the ones in the src/services/notes.ts file on the Components page; visible is the notes visible in the list layout (this.visible in a class and in Options, visible() in a function), and state is the component's own state (this.state in a class and in Options, a local state in a function).

Conditional rendering

x-wait — the primary method

The natural way in MotifJS to show an element based on a condition is the x-wait directive. As long as the function returns true, the element waits, and when it turns false the element becomes visible. It reads as "wait until this condition is met". x-display is the same mechanism turned around: it shows while true. Without JSX both are written in preconfig, because they are evaluated before the component is built:

<div class="rows" x-wait={() => this.visible.length === 0}>…</div>
<p class="list__empty" x-display={() => this.visible.length === 0}>No notes in this folder.</p>
<div class="rows" x-wait={() => visible().length === 0}>…</div>
<p class="list__empty" x-display={() => visible().length === 0}>No notes in this folder.</p>
motifComponent("div", {
  preconfig: (rows: ComponentBase) => rows.bindings.wait(() => this.visible.length === 0),
  initializeComponent: (rows: ComponentBase) => rows.class.add("rows"),
});
motifComponent("p", {
  preconfig: (p: ComponentBase) => p.bindings.display(() => this.visible.length === 0),
  initializeComponent: (p: ComponentBase) => {
    p.class.add("list__empty");
    p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("No notes in this folder.")));
  },
});
motifComponent("div", {
  preconfig: (rows: ComponentBase) => rows.bindings.wait(() => visible().length === 0),
  initializeComponent: (rows: ComponentBase) => rows.class.add("rows"),
});
motifComponent("p", {
  preconfig: (p: ComponentBase) => p.bindings.display(() => visible().length === 0),
  initializeComponent: (p: ComponentBase) => {
    p.class.add("list__empty");
    p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("No notes in this folder.")));
  },
});
<div class="rows" x-wait={() => this.visible.length === 0}>…</div>
<p class="list__empty" x-display={() => this.visible.length === 0}>No notes in this folder.</p>
<div class="rows" x-wait={() => visible().length === 0}>…</div>
<p class="list__empty" x-display={() => visible().length === 0}>No notes in this folder.</p>
motifComponent("div", {
  preconfig: (rows) => rows.bindings.wait(() => this.visible.length === 0),
  initializeComponent: (rows) => rows.class.add("rows"),
});
motifComponent("p", {
  preconfig: (p) => p.bindings.display(() => this.visible.length === 0),
  initializeComponent: (p) => {
    p.class.add("list__empty");
    p.controls.add(motifComponent("text", (node) => node.setText("No notes in this folder.")));
  },
});
motifComponent("div", {
  preconfig: (rows) => rows.bindings.wait(() => visible().length === 0),
  initializeComponent: (rows) => rows.class.add("rows"),
});
motifComponent("p", {
  preconfig: (p) => p.bindings.display(() => visible().length === 0),
  initializeComponent: (p) => {
    p.class.add("list__empty");
    p.controls.add(motifComponent("text", (node) => node.setText("No notes in this folder.")));
  },
});

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

Both work on every kind of tag: a plain DOM element, a class component (a class without an element included), a function and an Options component. The pinned badge in the sidebar is not built until the first pinned note arrives; CountBadge behaves the same whichever form it is written in:

<CountBadge count={() => this.notes.count("pinned")} x-wait={() => this.notes.count("pinned") === 0} />
<CountBadge count={() => notes.count("pinned")} x-wait={() => notes.count("pinned") === 0} />
motifComponent(CountBadge, {
  count: () => this.notes.count("pinned"),
  preconfig: (badge: ComponentBase) => badge.bindings.wait(() => this.notes.count("pinned") === 0),
});
motifComponent(CountBadge, {
  count: () => notes.count("pinned"),
  preconfig: (badge: ComponentBase) => badge.bindings.wait(() => notes.count("pinned") === 0),
});
<CountBadge count={() => this.notes.count("pinned")} x-wait={() => this.notes.count("pinned") === 0} />
<CountBadge count={() => notes.count("pinned")} x-wait={() => notes.count("pinned") === 0} />
motifComponent(CountBadge, {
  count: () => this.notes.count("pinned"),
  preconfig: (badge) => badge.bindings.wait(() => this.notes.count("pinned") === 0),
});
motifComponent(CountBadge, {
  count: () => notes.count("pinned"),
  preconfig: (badge) => badge.bindings.wait(() => notes.count("pinned") === 0),
});

Why x-wait?

In both modes a hidden element leaves the DOM; onDeactivated runs on the hidden component and the ones in its subtree, and onActivated when it is shown. Bindings keep running while it is hidden, and the element comes back with the current value when shown. hideStrategy makes the difference: the default 'auto' and 'placeholder' leave a comment node in place of the element and swap it back when showing, while 'detach' removes the element without a trace and puts it back according to its order in the parent when showing. The default also applies to list rows: a hidden or waiting row leaves a marker in its place, and when the list is sorted the marker moves together with the row. An element that starts out waiting puts a marker in its place before it is built, too. See Visibility.

&& — build and dispose

{condition && <X/>} is supported too, and it compiles to a bindings.when(conditionFn, buildFn) call. The difference is in its behaviour: every time the condition becomes true a new instance is built, and every time it becomes false that instance is disposed.

<div class="toolbar">
  {this.state.confirming && <span class="confirm">Delete this note permanently?</span>}
</div>
<div class="toolbar">
  {state.confirming && <span class="confirm">Delete this note permanently?</span>}
</div>
motifComponent("div", (bar: ComponentBase) => {
  bar.class.add("toolbar");
  bar.bindings.when(() => this.state.confirming, () => motifComponent("span", (span: ComponentBase) => {
    span.class.add("confirm");
    span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete this note permanently?")));
  }));
});
motifComponent("div", (bar: ComponentBase) => {
  bar.class.add("toolbar");
  bar.bindings.when(() => state.confirming, () => motifComponent("span", (span: ComponentBase) => {
    span.class.add("confirm");
    span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete this note permanently?")));
  }));
});
<div class="toolbar">
  {this.state.confirming && <span class="confirm">Delete this note permanently?</span>}
</div>
<div class="toolbar">
  {state.confirming && <span class="confirm">Delete this note permanently?</span>}
</div>
motifComponent("div", (bar) => {
  bar.class.add("toolbar");
  bar.bindings.when(() => this.state.confirming, () => motifComponent("span", (span) => {
    span.class.add("confirm");
    span.controls.add(motifComponent("text", (node) => node.setText("Delete this note permanently?")));
  }));
});
motifComponent("div", (bar) => {
  bar.class.add("toolbar");
  bar.bindings.when(() => state.confirming, () => motifComponent("span", (span) => {
    span.class.add("confirm");
    span.controls.add(motifComponent("text", (node) => node.setText("Delete this note permanently?")));
  }));
});

The branch is rebuilt only when the condition's value changes (Object.is; two falsy values count as the same). As long as the value stays the same, the branch keeps the component instance and its state: {notes.count("pinned") > 0 && <PinnedNotes />} keeps the same PinnedNotes while the pinned count goes 1 → 2 → 3. If the condition's value is an object ({state.selected && <NoteFields note={state.selected} />}), the branch is rebuilt when it is replaced with another object: a new NoteFields on every selection.

So choose && only when you want the building and disposing itself: when a third-party plugin really has to be destroyed, or when the content is so heavy that it should not be kept in memory. In every other case x-wait is cheaper and more predictable.

The condition is evaluated by JS truthiness: for false, null, undefined, 0, '' and NaN, nothing is rendered. So {state.selected && <NoteFields … />} (an object or null) and {notes.count("pinned") && <PinnedNotes />} (a number) behave as expected; 0 is not written to the screen. With a numeric condition, the branch is rebuilt every time the number changes (the value rule above); to keep the instance, write {notes.count("pinned") > 0 && <PinnedNotes />}. This rule holds both inside a real element and as a direct child of a fragment (<>…</>) root.

Which one to choose?

Situation Method
Show/hide, state must be kept x-wait / x-display
Closed at startup, heavy content x-wait (never built)
Must really be disposed on every close {condition && <X/>}
One of two branches the ternary operator
Only the text changes a getter

The ternary operator — one of two branches

<div class="toolbar">
  {this.state.confirming
    ? <span class="confirm">Delete this note permanently?</span>
    : <button type="button" class="button--sm danger" onclick={() => (this.state.confirming = true)}>Delete</button>}
</div>
<div class="toolbar">
  {state.confirming
    ? <span class="confirm">Delete this note permanently?</span>
    : <button type="button" class="button--sm danger" onclick={() => (state.confirming = true)}>Delete</button>}
</div>
motifComponent("div", (bar: ComponentBase) => {
  bar.class.add("toolbar");
  bar.bindings.ternary(
    () => this.state.confirming,
    (frame) => frame.navigate(motifComponent("span", (span: ComponentBase) => {
      span.class.add("confirm");
      span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete this note permanently?")));
    })),
    (frame) => frame.navigate(motifComponent("button", (button: ComponentBase) => {
      button.attr.add({ type: "button" });
      button.class.add("button--sm danger");
      button.motif.on("click", () => (this.state.confirming = true));
      button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete")));
    })),
  );
});
motifComponent("div", (bar: ComponentBase) => {
  bar.class.add("toolbar");
  bar.bindings.ternary(
    () => state.confirming,
    (frame) => frame.navigate(motifComponent("span", (span: ComponentBase) => {
      span.class.add("confirm");
      span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete this note permanently?")));
    })),
    (frame) => frame.navigate(motifComponent("button", (button: ComponentBase) => {
      button.attr.add({ type: "button" });
      button.class.add("button--sm danger");
      button.motif.on("click", () => (state.confirming = true));
      button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Delete")));
    })),
  );
});
<div class="toolbar">
  {this.state.confirming
    ? <span class="confirm">Delete this note permanently?</span>
    : <button type="button" class="button--sm danger" onclick={() => (this.state.confirming = true)}>Delete</button>}
</div>
<div class="toolbar">
  {state.confirming
    ? <span class="confirm">Delete this note permanently?</span>
    : <button type="button" class="button--sm danger" onclick={() => (state.confirming = true)}>Delete</button>}
</div>
motifComponent("div", (bar) => {
  bar.class.add("toolbar");
  bar.bindings.ternary(
    () => this.state.confirming,
    (frame) => frame.navigate(motifComponent("span", (span) => {
      span.class.add("confirm");
      span.controls.add(motifComponent("text", (node) => node.setText("Delete this note permanently?")));
    })),
    (frame) => frame.navigate(motifComponent("button", (button) => {
      button.attr.add({ type: "button" });
      button.class.add("button--sm danger");
      button.motif.on("click", () => (this.state.confirming = true));
      button.controls.add(motifComponent("text", (node) => node.setText("Delete")));
    })),
  );
});
motifComponent("div", (bar) => {
  bar.class.add("toolbar");
  bar.bindings.ternary(
    () => state.confirming,
    (frame) => frame.navigate(motifComponent("span", (span) => {
      span.class.add("confirm");
      span.controls.add(motifComponent("text", (node) => node.setText("Delete this note permanently?")));
    })),
    (frame) => frame.navigate(motifComponent("button", (button) => {
      button.attr.add({ type: "button" });
      button.class.add("button--sm danger");
      button.motif.on("click", () => (state.confirming = true));
      button.controls.add(motifComponent("text", (node) => node.setText("Delete")));
    })),
  );
});

Each branch gets a Frame and places its content with frame.navigate(...). As the condition changes, the matching branch is shown and the other is disposed; if the old branch has a leave transition it is played, and the order follows the transition.mode of the nearest element ancestor (see Styling and Animation). If both branches are light, this is the most readable form; if they are heavy, putting the two elements side by side with x-wait / x-display keeps the instances. If a branch has several roots, give <>…</> in JSX and motifFragment without JSX; the confirmation in the editor's toolbar works like that (see Fragments for branches and lists).

The branch is rebuilt only when the condition's value changes (Object.is); in a nested ternary, the inner branch is kept as well, as long as the outer condition's value stays the same. {notes.count("pinned") > 0 ? <PinnedNotes /> : <p class="hint">No pinned notes.</p>} does not rebuild PinnedNotes while the pinned count goes 1 → 2.

Component props inside a branch

A prop given to a component inside a branch with a plain expression is, just as outside a condition, the value at the moment the branch was built. <CountBadge count={notes.count("pinned")} /> does not see a note pinned later; the branch is not rebuilt as long as the condition's value does not change. To keep the prop live, pass a getter and read it on the receiving side with the Bind<T> contract (see Ternaries in attributes). Without JSX you write the getter yourself anyway:

<CountBadge count={() => this.notes.count("pinned")} />
<CountBadge count={() => notes.count("pinned")} />
motifComponent(CountBadge, { count: () => this.notes.count("pinned") });
motifComponent(CountBadge, { count: () => notes.count("pinned") });
<CountBadge count={() => this.notes.count("pinned")} />
<CountBadge count={() => notes.count("pinned")} />
motifComponent(CountBadge, { count: () => this.notes.count("pinned") });
motifComponent(CountBadge, { count: () => notes.count("pinned") });

CountBadge takes the prop as count: Bind<number> and reads it with read(props.count) (read(this.props.count) in a class); either a value or a getter can be given (see Common attributes).

Conditional text inside a getter

For simple text a getter is enough, and the element does not even change:

<button type="button" onclick={() => this.notes.togglePin(note)}>{() => (note.pinned ? "Pinned" : "Pin")}</button>
<button type="button" onclick={() => notes.togglePin(note)}>{() => (note.pinned ? "Pinned" : "Pin")}</button>
const pin = motifComponent("button", (button: ComponentBase) => {
  button.attr.add({ type: "button" });
  button.motif.on("click", () => this.notes.togglePin(note));
  button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
const pin = motifComponent("button", (button: ComponentBase) => {
  button.attr.add({ type: "button" });
  button.motif.on("click", () => notes.togglePin(note));
  button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
<button type="button" onclick={() => this.notes.togglePin(note)}>{() => (note.pinned ? "Pinned" : "Pin")}</button>
<button type="button" onclick={() => notes.togglePin(note)}>{() => (note.pinned ? "Pinned" : "Pin")}</button>
const pin = motifComponent("button", (button) => {
  button.attr.add({ type: "button" });
  button.motif.on("click", () => this.notes.togglePin(note));
  button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
const pin = motifComponent("button", (button) => {
  button.attr.add({ type: "button" });
  button.motif.on("click", () => notes.togglePin(note));
  button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});

The getter in JSX compiles to bindings.ternary, and the two branches are text nodes. Without JSX a single binding writes the same text: bindings.text changes the element's textContent, and if the element has other children they are removed as well.

A block-bodied getter

A getter in child position can also be written with a block body; the statements before return run in the condition's getter on every evaluation, and the branches see those variables:

The condition function can also prepare the value the branches will use: the function runs on every evaluation, and the branches read the same variable:

{() => { const label = note.title.trim() || "Untitled"; return note.pinned ? <b>{label}</b> : <i>{label}</i>; }}
let label = "";
row.bindings.ternary(
  () => {
    label = note.title.trim() || "Untitled";
    return note.pinned;
  },
  (frame) => frame.navigate(motifComponent("b", (b: ComponentBase) => b.setText(label))),
  (frame) => frame.navigate(motifComponent("i", (i: ComponentBase) => i.setText(label))),
);
{() => { const label = note.title.trim() || "Untitled"; return note.pinned ? <b>{label}</b> : <i>{label}</i>; }}
let label = "";
row.bindings.ternary(
  () => {
    label = note.title.trim() || "Untitled";
    return note.pinned;
  },
  (frame) => frame.navigate(motifComponent("b", (b) => b.setText(label))),
  (frame) => frame.navigate(motifComponent("i", (i) => i.setText(label))),
);

The branch reads the variable at the moment it is built: when note.pinned changes, the branch changes and takes the current value of label, but as long as the condition stays the same, a change to the title does not show up in the branch that is already built. A value that has to stay live inside the branch is written there as a binding.

Write a getter in the branch: <b>{() => note.title.trim() || "Untitled"}</b>. If there is an expression that produces JSX or a class declaration among the statements, the compiler cannot turn the getter into bindings.ternary; it warns with MJX016, and the getter runs as bindings.method, so the content is rebuilt on every change. In a block-bodied function that takes a parameter ({(x) => { const label = …; return … ? … : …; }}), the condition is moved out of the closure, the local variables are not visible in the branches and a ReferenceError is thrown; the compiler warns about this with MJX001 (see Compile-time warnings).

Write bindings.text(() => note.title.trim() || "Untitled") in the branch.

Showing lists — .map

Turning an array into items with .map sets up a reactive list binding; it compiles to bindings.list(itemsFn, buildFn). Without JSX the list is set up on a holder component that has no element (motifComponent({ initializeComponent })), and each row carries its key through indexkey:

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

export class NoteList extends Component {
  notes = this.getService(NotesService)!;

  override view() {
    return (
      <ul class="notes">
        {this.notes.list("notes").map((note) => (
          <li key={note.id} class={() => (note.pinned ? "note pinned" : "note")}>
            <input type="checkbox" checked={() => note.pinned} onchange={() => this.notes.togglePin(note)} />
            <span>{note.title}</span>
            <button type="button" onclick={() => this.notes.archive(note)}>Archive</button>
          </li>
        ))}
      </ul>
    );
  }
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteList() {
  const notes = FromService(NotesService)!;

  return (
    <ul class="notes">
      {notes.list("notes").map((note) => (
        <li key={note.id} class={() => (note.pinned ? "note pinned" : "note")}>
          <input type="checkbox" checked={() => note.pinned} onchange={() => notes.togglePin(note)} />
          <span>{note.title}</span>
          <button type="button" onclick={() => notes.archive(note)}>Archive</button>
        </li>
      ))}
    </ul>
  );
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteList = () => motifOptions({
  el: "ul",

  view() {
    const notes = FromService(NotesService)!;
    this.class.add("notes");
    return (
      <>
        {notes.list("notes").map((note) => (
          <li key={note.id} class={() => (note.pinned ? "note pinned" : "note")}>
            <input type="checkbox" checked={() => note.pinned} onchange={() => notes.togglePin(note)} />
            <span>{note.title}</span>
            <button type="button" onclick={() => notes.archive(note)}>Archive</button>
          </li>
        ))}
      </>
    );
  },
});
import { Component, ComponentBase, motifComponent } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export class NoteList extends Component {
  notes = this.getService(NotesService)!;

  override view() {
    return motifComponent("ul", (ul: ComponentBase) => {
      ul.class.add("notes");
      ul.controls.add(motifComponent({
        initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => this.notes.list("notes"), (note: Note) => this.row(note)),
      }));
    });
  }

  private row(note: Note) {
    return motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li: ComponentBase) => {
        li.class.add(() => (note.pinned ? "note pinned" : "note"));
        const pin = motifComponent("input", (input: ComponentBase) => {
          input.attr.add({ type: "checkbox" });
          input.bindings.add("checked", () => note.pinned);
          input.motif.on("change", () => this.notes.togglePin(note));
        });
        const title = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))));
        const archive = motifComponent("button", (button: ComponentBase) => {
          button.attr.add({ type: "button" });
          button.motif.on("click", () => this.notes.archive(note));
          button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
        });
        li.controls.add(pin, title, archive);
      },
    });
  }
}
import { ComponentBase, FromService, motifComponent } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export function NoteList() {
  const notes = FromService(NotesService)!;

  const row = (note: Note) =>
    motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li: ComponentBase) => {
        li.class.add(() => (note.pinned ? "note pinned" : "note"));
        const pin = motifComponent("input", (input: ComponentBase) => {
          input.attr.add({ type: "checkbox" });
          input.bindings.add("checked", () => note.pinned);
          input.motif.on("change", () => notes.togglePin(note));
        });
        const title = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))));
        const archive = motifComponent("button", (button: ComponentBase) => {
          button.attr.add({ type: "button" });
          button.motif.on("click", () => notes.archive(note));
          button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
        });
        li.controls.add(pin, title, archive);
      },
    });

  return motifComponent("ul", (ul: ComponentBase) => {
    ul.class.add("notes");
    ul.controls.add(motifComponent({
      initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("notes"), (note: Note) => row(note)),
    }));
  });
}
import { ComponentBase, FromService, motifComponent, motifOptions } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export const NoteList = () => motifOptions({
  el: "ul",

  view() {
    const notes = FromService(NotesService)!;
    const row = (note: Note) =>
      motifComponent("li", {
        indexkey: () => note.id,
        initializeComponent: (li: ComponentBase) => {
          li.class.add(() => (note.pinned ? "note pinned" : "note"));
          const pin = motifComponent("input", (input: ComponentBase) => {
            input.attr.add({ type: "checkbox" });
            input.bindings.add("checked", () => note.pinned);
            input.motif.on("change", () => notes.togglePin(note));
          });
          const title = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))));
          const archive = motifComponent("button", (button: ComponentBase) => {
            button.attr.add({ type: "button" });
            button.motif.on("click", () => notes.archive(note));
            button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
          });
          li.controls.add(pin, title, archive);
        },
      });

    this.class.add("notes");
    return motifComponent({
      initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("notes"), (note: Note) => row(note)),
    });
  },
});
import { Component } from "@motifx/core";
import { NotesService } from "../services/notes";

export class NoteList extends Component {
  notes = this.getService(NotesService);

  view() {
    return (
      <ul class="notes">
        {this.notes.list("notes").map((note) => (
          <li key={note.id} class={() => (note.pinned ? "note pinned" : "note")}>
            <input type="checkbox" checked={() => note.pinned} onchange={() => this.notes.togglePin(note)} />
            <span>{note.title}</span>
            <button type="button" onclick={() => this.notes.archive(note)}>Archive</button>
          </li>
        ))}
      </ul>
    );
  }
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteList() {
  const notes = FromService(NotesService);

  return (
    <ul class="notes">
      {notes.list("notes").map((note) => (
        <li key={note.id} class={() => (note.pinned ? "note pinned" : "note")}>
          <input type="checkbox" checked={() => note.pinned} onchange={() => notes.togglePin(note)} />
          <span>{note.title}</span>
          <button type="button" onclick={() => notes.archive(note)}>Archive</button>
        </li>
      ))}
    </ul>
  );
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteList = () => motifOptions({
  el: "ul",

  view() {
    const notes = FromService(NotesService);
    this.class.add("notes");
    return (
      <>
        {notes.list("notes").map((note) => (
          <li key={note.id} class={() => (note.pinned ? "note pinned" : "note")}>
            <input type="checkbox" checked={() => note.pinned} onchange={() => notes.togglePin(note)} />
            <span>{note.title}</span>
            <button type="button" onclick={() => notes.archive(note)}>Archive</button>
          </li>
        ))}
      </>
    );
  },
});
import { Component, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";

export class NoteList extends Component {
  notes = this.getService(NotesService);

  view() {
    return motifComponent("ul", (ul) => {
      ul.class.add("notes");
      ul.controls.add(motifComponent({
        initializeComponent: (holder) => holder.bindings.list(() => this.notes.list("notes"), (note) => this.row(note)),
      }));
    });
  }

  row(note) {
    return motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li) => {
        li.class.add(() => (note.pinned ? "note pinned" : "note"));
        const pin = motifComponent("input", (input) => {
          input.attr.add({ type: "checkbox" });
          input.bindings.add("checked", () => note.pinned);
          input.motif.on("change", () => this.notes.togglePin(note));
        });
        const title = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))));
        const archive = motifComponent("button", (button) => {
          button.attr.add({ type: "button" });
          button.motif.on("click", () => this.notes.archive(note));
          button.controls.add(motifComponent("text", (node) => node.setText("Archive")));
        });
        li.controls.add(pin, title, archive);
      },
    });
  }
}
import { FromService, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";

export function NoteList() {
  const notes = FromService(NotesService);

  const row = (note) =>
    motifComponent("li", {
      indexkey: () => note.id,
      initializeComponent: (li) => {
        li.class.add(() => (note.pinned ? "note pinned" : "note"));
        const pin = motifComponent("input", (input) => {
          input.attr.add({ type: "checkbox" });
          input.bindings.add("checked", () => note.pinned);
          input.motif.on("change", () => notes.togglePin(note));
        });
        const title = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))));
        const archive = motifComponent("button", (button) => {
          button.attr.add({ type: "button" });
          button.motif.on("click", () => notes.archive(note));
          button.controls.add(motifComponent("text", (node) => node.setText("Archive")));
        });
        li.controls.add(pin, title, archive);
      },
    });

  return motifComponent("ul", (ul) => {
    ul.class.add("notes");
    ul.controls.add(motifComponent({
      initializeComponent: (holder) => holder.bindings.list(() => notes.list("notes"), (note) => row(note)),
    }));
  });
}
import { FromService, motifComponent, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const NoteList = () => motifOptions({
  el: "ul",

  view() {
    const notes = FromService(NotesService);
    const row = (note) =>
      motifComponent("li", {
        indexkey: () => note.id,
        initializeComponent: (li) => {
          li.class.add(() => (note.pinned ? "note pinned" : "note"));
          const pin = motifComponent("input", (input) => {
            input.attr.add({ type: "checkbox" });
            input.bindings.add("checked", () => note.pinned);
            input.motif.on("change", () => notes.togglePin(note));
          });
          const title = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))));
          const archive = motifComponent("button", (button) => {
            button.attr.add({ type: "button" });
            button.motif.on("click", () => notes.archive(note));
            button.controls.add(motifComponent("text", (node) => node.setText("Archive")));
          });
          li.controls.add(pin, title, archive);
        },
      });

    this.class.add("notes");
    return motifComponent({
      initializeComponent: (holder) => holder.bindings.list(() => notes.list("notes"), (note) => row(note)),
    });
  },
});

When the array changes (adding, removing, sorting), MotifJS applies only the difference to the DOM; it does not recreate the whole list. notes.list("notes") filters out archived notes, so archive(note) drops the row from the list; togglePin updates that row's class and box; and since the list puts pinned notes first, the row moves to the top without being rebuilt.

key and item identity

Rows are matched by the identity of the item object: if the same object (the raw object of the reactive proxy) is still in the new array, its row is reused and moved in the DOM if needed, and the row of an object that is no longer in the array is disposed. If new objects are put into the array (for example a fresh list from the server), the rows are rebuilt even if they carry the same id. The same object can appear in the array more than once (push(items[0]), splice(i, 0, items[j])); each occurrence gets its own row, all of them show the same object, and a change to the object shows up in all of them. Array methods do not copy items; if you need an independent copy, copy it explicitly: items.splice(i, 0, { ...items[j] }). In arrays of primitive values (string[]), the same value at the same position keeps its row; if the template function does not take the second argument (index), the same value that changes position keeps its row too. For object items the row is reused when it moves, so the index the template receives is the position at the moment the row was built; it is not updated after sorting.

key takes no part in this matching and does not change how the DOM is reused; it is optional and identifies the item. If you write one, give it a stable, unique value (usually note.id): if the same key is seen on two items, development mode prints an MJX202 warning. The names x-key and indexkey are the same as key; without JSX, use indexkey: () => note.id.

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), every row reads the key written last, and MJX202 arrives for no reason when the list updates. Give the root's props as a new object on every call (see Keys of the props object).

Reactivity inside items

List items are reactive too. In the row in NoteList, when note.title changes only that row's text is updated. When note.pinned changes, the row's class and checkbox are updated; since the list also reads its sort order from this field, it leaves the row built in place and moves it to its new position. The row template has to return a component (an instance, a class or a factory); any other value throws MJX201, and in that update the list places no rows at all. To skip a row based on a condition, do not return null; filter the array first (see Filtering).

The second argument of .map works as in JS: inside items.map(function (i) { … }, ctx), this is ctx. An arrow function keeps its own this.

The row receives the element as a prop. Giving a row only an id and looking up the element in every row with items.find((n) => n.id === id) subscribes every row to every element, n² subscriptions for n rows (see The trap of every row scanning the list).

Filtering

A .filter().map() chain is supported; if the condition depends on reactive sources, the filter is reactive too:

<ul class="pinned">
  {this.notes.list("notes").filter((note) => note.pinned).map((note) => <li key={note.id}>{note.title}</li>)}
</ul>
<ul class="pinned">
  {notes.list("notes").filter((note) => note.pinned).map((note) => <li key={note.id}>{note.title}</li>)}
</ul>
motifComponent("ul", (ul: ComponentBase) => {
  ul.class.add("pinned");
  ul.controls.add(motifComponent({
    initializeComponent: (holder: ComponentBase) => holder.bindings.list(
      () => this.notes.list("notes").filter((note) => note.pinned),
      (note: Note) => motifComponent("li", { indexkey: () => note.id, initializeComponent: (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))) }),
    ),
  }));
});
motifComponent("ul", (ul: ComponentBase) => {
  ul.class.add("pinned");
  ul.controls.add(motifComponent({
    initializeComponent: (holder: ComponentBase) => holder.bindings.list(
      () => notes.list("notes").filter((note) => note.pinned),
      (note: Note) => motifComponent("li", { indexkey: () => note.id, initializeComponent: (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))) }),
    ),
  }));
});
<ul class="pinned">
  {this.notes.list("notes").filter((note) => note.pinned).map((note) => <li key={note.id}>{note.title}</li>)}
</ul>
<ul class="pinned">
  {notes.list("notes").filter((note) => note.pinned).map((note) => <li key={note.id}>{note.title}</li>)}
</ul>
motifComponent("ul", (ul) => {
  ul.class.add("pinned");
  ul.controls.add(motifComponent({
    initializeComponent: (holder) => holder.bindings.list(
      () => this.notes.list("notes").filter((note) => note.pinned),
      (note) => motifComponent("li", { indexkey: () => note.id, initializeComponent: (li) => li.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))) }),
    ),
  }));
});
motifComponent("ul", (ul) => {
  ul.class.add("pinned");
  ul.controls.add(motifComponent({
    initializeComponent: (holder) => holder.bindings.list(
      () => notes.list("notes").filter((note) => note.pinned),
      (note) => motifComponent("li", { indexkey: () => note.id, initializeComponent: (li) => li.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))) }),
    ),
  }));
});

A switch-like multi-branch

To show different content depending on a discriminant value, there is the switchCase binding. In JSX it is written as a switch inside a block-bodied getter; the compiler turns the branch that each case returns into switchCase:

The settings page shows the name of the selected theme, and writes nothing for the system theme:

<span class="theme">
  {() => {
    switch (this.settings.state.theme) {
      case "light": return <i>Light theme</i>;
      case "dark":  return <i>Dark theme</i>;
    }
  }}
</span>
<span class="theme">
  {() => {
    switch (settings.state.theme) {
      case "light": return <i>Light theme</i>;
      case "dark":  return <i>Dark theme</i>;
    }
  }}
</span>
motifComponent("span", (label: ComponentBase) => {
  label.class.add("theme");
  label.bindings.switchCase(() => this.settings.state.theme, {
    light: (frame) => frame.navigate(motifComponent("i", (i: ComponentBase) => i.setText("Light theme"))),
    dark: (frame) => frame.navigate(motifComponent("i", (i: ComponentBase) => i.setText("Dark theme"))),
  });
});
motifComponent("span", (label: ComponentBase) => {
  label.class.add("theme");
  label.bindings.switchCase(() => settings.state.theme, {
    light: (frame) => frame.navigate(motifComponent("i", (i: ComponentBase) => i.setText("Light theme"))),
    dark: (frame) => frame.navigate(motifComponent("i", (i: ComponentBase) => i.setText("Dark theme"))),
  });
});
<span class="theme">
  {() => {
    switch (this.settings.state.theme) {
      case "light": return <i>Light theme</i>;
      case "dark":  return <i>Dark theme</i>;
    }
  }}
</span>
<span class="theme">
  {() => {
    switch (settings.state.theme) {
      case "light": return <i>Light theme</i>;
      case "dark":  return <i>Dark theme</i>;
    }
  }}
</span>
motifComponent("span", (label) => {
  label.class.add("theme");
  label.bindings.switchCase(() => this.settings.state.theme, {
    light: (frame) => frame.navigate(motifComponent("i", (i) => i.setText("Light theme"))),
    dark: (frame) => frame.navigate(motifComponent("i", (i) => i.setText("Dark theme"))),
  });
});
motifComponent("span", (label) => {
  label.class.add("theme");
  label.bindings.switchCase(() => settings.state.theme, {
    light: (frame) => frame.navigate(motifComponent("i", (i) => i.setText("Light theme"))),
    dark: (frame) => frame.navigate(motifComponent("i", (i) => i.setText("Dark theme"))),
  });
});

Each branch gets a Frame, and you place that branch's content with frame.navigate(...). The discriminant's value is turned into a key with String(...); if the same value comes in a row, the branch is not rebuilt, and when going from one unmatched value to another, the default branch stays in place as well. If no case matches (like system), the spot is emptied and the previous branch is disposed. The spot is also emptied when a branch returns null or undefined (default: return null, return saving ? <i>…</i> : null). Without JSX, the default branch is the third argument; frame.navigate(null) places nothing and the previous content stays in place, so to empty the spot use (frame) => frame.flush().

case labels must be string, number or boolean literals. Statements written in the block body before the switch run in the discriminant's getter on every evaluation, and the branches see those variables; a branch reads a variable at the moment it is built, and as long as the discriminant stays the same, a change to the variable does not show up in the branch; statements before return inside a case run while the branch is being built; with case "a": case "b": return <X/>;, "a" shows X as well; a branch that leaves with break and the statements after the switch run on the default path. If there is no default branch, a return <X/> that comes after the switch becomes the default branch, and that content is shown when no case matches (return null empties the spot). A shape the compiler cannot turn into switchCase runs as written, as bindings.method, and the compiler warns with MJX016: a case label that is not a literal or is written twice, a second default or switch, a return before the switch, a statement that produces JSX or a class declaration, a break/continue inside a case that is not the last statement or that carries a label. With such code branches are not kept, and the content is rebuilt on every change.

Using the binding API directly

JSX is a short way of writing these calls; the same API can be written without JSX and called directly inside initializeComponent / onConfig or in tests. Rather than guessing which JSX form comes down to which call, print it: npx motif-explain file.tsx (see What did this expression compile to?).

Method What it does
bindings.add(prop, source, member?, format?, formatInfo?) Sets up a one-way binding to a DOM property/attribute.
bindings.text(fn) Binds textContent reactively (x-text).
bindings.value(fn) Binds value reactively (x-value).
bindings.html(fn) Binds innerHTML reactively (x-html).
bindings.when(condFn, renderFn) Conditional content (&&) — a new instance when the condition's value changes and is truthy.
bindings.ternary(condFn, trueFn, falseFn) A two-branch condition; a branch is rebuilt when the condition's value changes.
bindings.list(itemsFn, renderFn) Showing a list (.map); rows are matched to the item object.
bindings.loop(itemsFn, renderFn) The same as list; an alternative name.
bindings.switchCase(discFn, cases, defaultFn?) Multiple branches.
bindings.method(fn) Reactive text or a single piece of reactive content; fn is called once per run. Strings, numbers, booleans and bigints are written as text; a component is placed in a Frame. The result can switch between text and a component, and the new content is drawn in the same spot. If null/undefined arrives, the spot is emptied: previous content that was a component is disposed, and previous text is cleared; content that comes later fills the spot again.
bindings.watch(fn) A side-effect watcher (x-watch; stops automatically on disposal).
bindings.model(source, member?, format?, formatInfo?) / bindings.model(getter, setter) A two-way binding (see Forms).
bindings.writeModel(value) Writes the value back to the model/x-model bindings on the component; returns true if it wrote (see Forms).
bindings.wait(fn) / bindings.display(fn) Waiting / visibility (x-wait / x-display) — the instance is kept; called inside preconfig.
bindings.remove(binding) / bindings.clear() Remove one binding (or all of them) from the collection and turn it off; with the binding returned by the add/html/list/model/wait/display calls. when, ternary, switchCase, method and watch are not registered bindings; they stop together with the component.
bindings.items A copy of the registered bindings (those set up with add/html/list/model/wait/display).

A bindings.add example

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

bindings.add signatures:

Call Value read
add(propertyName, dataSource) dataSource itself; if it is a getter, the value it returns
add(propertyName, dataSource, dataMember) The dataMember field on dataSource (it can be a dotted path)
add(propertyName, dataSource, dataMember, formatString) The same field, formatted with formatString
add(propertyName, dataSource, dataMember, formatString, formatInfo) The same field; locale and currency come from formatInfo ({ locale, currency })
add(binding) A ready binding object

Every form returns the added binding (IBaseBinding); propertyName is the name of the property to write ("textContent", "value" …).

dataSource can also be a getter on its own (add("textContent", () => note.title)); when dataMember is given, that field is read, and it can be a dotted path ("author.name"). The formatString codes: for numbers C currency, P percent (0.25 → 25% in en-US), N/N2 fixed decimals (2 by default), and the number codes can be written in lower case too; for Date values d date and t time. A value of any other type, or an unrecognized code, is written with toString(). Locale and currency come from formatInfo: bindings.add("textContent", order, "total", "C", { locale: "en-US", currency: "USD" }) → $1,234.50. Without locale, the runtime's default locale is used; without currency, C writes the amount with two decimals and no symbol.

A bindings.list example

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

The first argument is the item source: a function that returns an array or an iterable. The second is the row template, and it returns a component.

In the function component, visible() and folder() are local functions inside the component function.

Large lists

When thousands of rows enter a page at once, the browser cannot respond until all of them have been built and laid out. MotifJS has two tools for this:

Chunked building — x-chunk

The directive is written without a value on the element that receives the rows. The archive page lists all archived notes in a single table: the first 100 rows arrive with the page, and the rest are spread over the following frames.

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

export class ArchivePage extends Component {
  notes = this.getService(NotesService)!;

  override view() {
    return (
      <table class="archive">
        <tbody x-chunk>
          {this.notes.list("archive").map((note) => (
            <tr key={note.id}>
              <td>{note.title}</td>
              <td>{() => (note.pinned ? "Pinned" : "")}</td>
            </tr>
          ))}
        </tbody>
      </table>
    );
  }
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";

export function ArchivePage() {
  const notes = FromService(NotesService)!;

  return (
    <table class="archive">
      <tbody x-chunk>
        {notes.list("archive").map((note) => (
          <tr key={note.id}>
            <td>{note.title}</td>
            <td>{() => (note.pinned ? "Pinned" : "")}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const ArchivePage = () => motifOptions({
  el: "table",

  view() {
    const notes = FromService(NotesService)!;
    this.class.add("archive");
    return (
      <tbody x-chunk>
        {notes.list("archive").map((note) => (
          <tr key={note.id}>
            <td>{note.title}</td>
            <td>{() => (note.pinned ? "Pinned" : "")}</td>
          </tr>
        ))}
      </tbody>
    );
  },
});
import { Component, ComponentBase, motifComponent } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export class ArchivePage extends Component {
  notes = this.getService(NotesService)!;

  override view() {
    return motifComponent("table", (table: ComponentBase) => {
      table.class.add("archive");
      table.controls.add(motifComponent("tbody", {
        options: { chunk: true },
        initializeComponent: (body: ComponentBase) => body.controls.add(motifComponent({
          initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => this.notes.list("archive"), (note: Note) => this.row(note)),
        })),
      }));
    });
  }

  private row(note: Note) {
    return motifComponent("tr", {
      indexkey: () => note.id,
      initializeComponent: (tr: ComponentBase) => {
        const title = motifComponent("td", (td: ComponentBase) => td.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", () => note.title))));
        const pinned = motifComponent("td", (td: ComponentBase) => td.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", () => (note.pinned ? "Pinned" : "")))));
        tr.controls.add(title, pinned);
      },
    });
  }
}
import { ComponentBase, FromService, motifComponent } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export function ArchivePage() {
  const notes = FromService(NotesService)!;

  const row = (note: Note) =>
    motifComponent("tr", {
      indexkey: () => note.id,
      initializeComponent: (tr: ComponentBase) => {
        const title = motifComponent("td", (td: ComponentBase) => td.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", () => note.title))));
        const pinned = motifComponent("td", (td: ComponentBase) => td.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", () => (note.pinned ? "Pinned" : "")))));
        tr.controls.add(title, pinned);
      },
    });

  return motifComponent("table", (table: ComponentBase) => {
    table.class.add("archive");
    table.controls.add(motifComponent("tbody", {
      options: { chunk: true },
      initializeComponent: (body: ComponentBase) => body.controls.add(motifComponent({
        initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("archive"), (note: Note) => row(note)),
      })),
    }));
  });
}
import { ComponentBase, FromService, motifComponent, motifOptions } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";

export const ArchivePage = () => motifOptions({
  el: "table",

  view() {
    const notes = FromService(NotesService)!;
    const row = (note: Note) =>
      motifComponent("tr", {
        indexkey: () => note.id,
        initializeComponent: (tr: ComponentBase) => {
          const title = motifComponent("td", (td: ComponentBase) => td.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", () => note.title))));
          const pinned = motifComponent("td", (td: ComponentBase) => td.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", () => (note.pinned ? "Pinned" : "")))));
          tr.controls.add(title, pinned);
        },
      });

    this.class.add("archive");
    return motifComponent("tbody", {
      options: { chunk: true },
      initializeComponent: (body: ComponentBase) => body.controls.add(motifComponent({
        initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("archive"), (note: Note) => row(note)),
      })),
    });
  },
});
import { Component } from "@motifx/core";
import { NotesService } from "../services/notes";

export class ArchivePage extends Component {
  notes = this.getService(NotesService);

  view() {
    return (
      <table class="archive">
        <tbody x-chunk>
          {this.notes.list("archive").map((note) => (
            <tr key={note.id}>
              <td>{note.title}</td>
              <td>{() => (note.pinned ? "Pinned" : "")}</td>
            </tr>
          ))}
        </tbody>
      </table>
    );
  }
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";

export function ArchivePage() {
  const notes = FromService(NotesService);

  return (
    <table class="archive">
      <tbody x-chunk>
        {notes.list("archive").map((note) => (
          <tr key={note.id}>
            <td>{note.title}</td>
            <td>{() => (note.pinned ? "Pinned" : "")}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const ArchivePage = () => motifOptions({
  el: "table",

  view() {
    const notes = FromService(NotesService);
    this.class.add("archive");
    return (
      <tbody x-chunk>
        {notes.list("archive").map((note) => (
          <tr key={note.id}>
            <td>{note.title}</td>
            <td>{() => (note.pinned ? "Pinned" : "")}</td>
          </tr>
        ))}
      </tbody>
    );
  },
});
import { Component, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";

export class ArchivePage extends Component {
  notes = this.getService(NotesService);

  view() {
    return motifComponent("table", (table) => {
      table.class.add("archive");
      table.controls.add(motifComponent("tbody", {
        options: { chunk: true },
        initializeComponent: (body) => body.controls.add(motifComponent({
          initializeComponent: (holder) => holder.bindings.list(() => this.notes.list("archive"), (note) => this.row(note)),
        })),
      }));
    });
  }

  row(note) {
    return motifComponent("tr", {
      indexkey: () => note.id,
      initializeComponent: (tr) => {
        const title = motifComponent("td", (td) => td.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", () => note.title))));
        const pinned = motifComponent("td", (td) => td.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", () => (note.pinned ? "Pinned" : "")))));
        tr.controls.add(title, pinned);
      },
    });
  }
}
import { FromService, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";

export function ArchivePage() {
  const notes = FromService(NotesService);

  const row = (note) =>
    motifComponent("tr", {
      indexkey: () => note.id,
      initializeComponent: (tr) => {
        const title = motifComponent("td", (td) => td.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", () => note.title))));
        const pinned = motifComponent("td", (td) => td.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", () => (note.pinned ? "Pinned" : "")))));
        tr.controls.add(title, pinned);
      },
    });

  return motifComponent("table", (table) => {
    table.class.add("archive");
    table.controls.add(motifComponent("tbody", {
      options: { chunk: true },
      initializeComponent: (body) => body.controls.add(motifComponent({
        initializeComponent: (holder) => holder.bindings.list(() => notes.list("archive"), (note) => row(note)),
      })),
    }));
  });
}
import { FromService, motifComponent, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";

export const ArchivePage = () => motifOptions({
  el: "table",

  view() {
    const notes = FromService(NotesService);
    const row = (note) =>
      motifComponent("tr", {
        indexkey: () => note.id,
        initializeComponent: (tr) => {
          const title = motifComponent("td", (td) => td.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", () => note.title))));
          const pinned = motifComponent("td", (td) => td.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", () => (note.pinned ? "Pinned" : "")))));
          tr.controls.add(title, pinned);
        },
      });

    this.class.add("archive");
    return motifComponent("tbody", {
      options: { chunk: true },
      initializeComponent: (body) => body.controls.add(motifComponent({
        initializeComponent: (holder) => holder.bindings.list(() => notes.list("archive"), (note) => row(note)),
      })),
    });
  },
});

In every frame the framework measures two durations: the time to build the children, and the time the browser takes to lay them out and paint them. In the next frame it builds as many children as keep the sum of the two at about 50 ms. A small list finishes in a few frames; as the container grows, more children are built per frame. If you want a lower bound on the number built, give a value: x-chunk={50} builds exactly 50 children in the first round and at least 50 in every following frame, more if the frame's time allows.

The browser lays out the container that children are added to again in every frame, and that work grows with the size of the container. It is most expensive for a table: as rows are added, the table measures its columns again. Once laying out the container alone takes more than 50 ms, frames get longer. At that point the framework builds more children per frame and spreads the cost over fewer frames, but it cannot make the frames shorter. In a desktop browser this limit is around 10–30 thousand rows for table rows and a few tens of thousands for plain rows; on slow devices it comes earlier. For lists of this size, use virtual scrolling.

The count is kept for every child that goes into the element: tags written directly, .map rows, and children coming from a condition or a fragment all share the same budget. .map rows are not created until their turn comes: the row function is not called and the component is not built. This applies to a list written with {notes.list("archive").map(...)} as in the example. If the rows are written inside a getter, as {() => notes.list("archive").map(...)}, all row components are created up front and only their building is chunked. No row is ever built for an item that leaves the array in the meantime; if the array is re-sorted, the row is built at its new position and index is the current position. A child with its own x-wait follows its own condition and is not part of the chunking. A child whose turn comes follows the usual lifecycle (onConfigured, onBuilt, onMounted). Chunks keep flowing while the tab is in the background. If building a child throws, it is reported with MJX130, and the other children of the chunk keep being built. A marker stays in place of the .map row that failed, and the row is created again on the list's next update.

The first chunk belongs to the element: children added together (in the same run, up to an await) share the first 100, and rows that arrive later with push start with a new first chunk. If there are several chunked elements on the same page, each gets its share in every frame, and the frame time is measured for all of them together. Without JSX, give options: { chunk: true } or a number, or set motif.options.chunk before setup; 0 or false turns chunking off. If a function is given instead of a value, it is called once during setup and not tracked afterwards. On a chunked page, scroll memory retries the position in every frame while rows arrive, until it reaches the target or settleMs runs out — see Scroll memory.

Next step

To learn about event handling, go on to Events.