Core concepts
JSX and Templates
How JSX comes down to core calls; text and attribute bindings, spread, class/style, x- directives, compile diagnostics and explain; the counterpart of every form without JSX.
In MotifJS, JSX does not produce a virtual tree at run time. @motifx/compiler turns every JSX element into a live component object (a motifComponent call), and an expression inside curly braces becomes either a prop of that component or a call made on it (attr.add, class.add, style, motif.on, bindings.*, controls.add). Whether a live binding is set up depends on the form of the expression: in child position {note.title} and {() => …} set up a binding, while a plain call such as {fmt(note.updatedAt)} is evaluated once (see Text and reactive expressions). This page covers what you can write inside JSX and which call each form comes down to. If Imperative is selected in the code choices, the examples show the same thing written without JSX (see Views without JSX).
The examples come from the notes application in the starter template. Note, Folder, FOLDERS and NotesService are in the src/services/notes.ts file on the Components page; note stands for the note in the list or the one that is open.
In the examples, this.notes is this service and this.state is the component's reactive state.
In the examples, notes is the service obtained with FromService, and state is the local state the component sets up with reactive.
Every element is a component
The compiler does not produce HTML. Every element becomes a component object through a motifComponent (_mc) call, and the tree is built by adding these objects to each other's controls collections in code. The placeholder of the empty folder page (Components):
override view() {
return (
<div class="placeholder">
<p>{() => (this.notes.count(this.folder) ? "Select a note or write a new one." : FOLDERS[this.folder].empty)}</p>
<button type="button" class="accent" onclick={() => this.create()}>
New note
</button>
</div>
);
}
view() {
return (
<div class="placeholder">
<p>{() => (this.notes.count(this.folder) ? "Select a note or write a new one." : FOLDERS[this.folder].empty)}</p>
<button type="button" class="accent" onclick={() => this.create()}>
New note
</button>
</div>
);
}
return (
<div class="placeholder">
<p>{() => (notes.count(folder()) ? "Select a note or write a new one." : FOLDERS[folder()].empty)}</p>
<button type="button" class="accent" onclick={() => create()}>
New note
</button>
</div>
);
override view() {
return motifComponent("div", (box: ComponentBase) => {
box.class.add("placeholder");
const message = motifComponent("p", (p: ComponentBase) => {
p.bindings.ternary(
() => this.notes.count(this.folder),
(frame) => frame.navigate(motifComponent("text", (node: ComponentBase) => node.setText("Select a note or write a new one."))),
(frame) => frame.navigate(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", FOLDERS[this.folder], "empty"))),
);
});
const create = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("accent");
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("New note")));
});
box.controls.add(message, create);
});
}
view() {
return motifComponent("div", (box: ComponentBase) => {
box.class.add("placeholder");
const message = motifComponent("p", (p: ComponentBase) => {
p.bindings.ternary(
() => this.notes.count(this.folder),
(frame) => frame.navigate(motifComponent("text", (node: ComponentBase) => node.setText("Select a note or write a new one."))),
(frame) => frame.navigate(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", FOLDERS[this.folder], "empty"))),
);
});
const create = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("accent");
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("New note")));
});
box.controls.add(message, create);
});
}
return motifComponent("div", (box: ComponentBase) => {
box.class.add("placeholder");
const message = motifComponent("p", (p: ComponentBase) => {
p.bindings.ternary(
() => notes.count(folder()),
(frame) => frame.navigate(motifComponent("text", (node: ComponentBase) => node.setText("Select a note or write a new one."))),
(frame) => frame.navigate(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", FOLDERS[folder()], "empty"))),
);
});
const createButton = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("accent");
button.motif.on("click", () => create());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("New note")));
});
box.controls.add(message, createButton);
});
view() {
return (
<div class="placeholder">
<p>{() => (this.notes.count(this.folder) ? "Select a note or write a new one." : FOLDERS[this.folder].empty)}</p>
<button type="button" class="accent" onclick={() => this.create()}>
New note
</button>
</div>
);
}
return (
<div class="placeholder">
<p>{() => (notes.count(folder()) ? "Select a note or write a new one." : FOLDERS[folder()].empty)}</p>
<button type="button" class="accent" onclick={() => create()}>
New note
</button>
</div>
);
view() {
return motifComponent("div", (box) => {
box.class.add("placeholder");
const message = motifComponent("p", (p) => {
p.bindings.ternary(
() => this.notes.count(this.folder),
(frame) => frame.navigate(motifComponent("text", (node) => node.setText("Select a note or write a new one."))),
(frame) => frame.navigate(motifComponent("text", (node) => node.bindings.add("textContent", FOLDERS[this.folder], "empty"))),
);
});
const create = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("accent");
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node) => node.setText("New note")));
});
box.controls.add(message, create);
});
}
return motifComponent("div", (box) => {
box.class.add("placeholder");
const message = motifComponent("p", (p) => {
p.bindings.ternary(
() => notes.count(folder()),
(frame) => frame.navigate(motifComponent("text", (node) => node.setText("Select a note or write a new one."))),
(frame) => frame.navigate(motifComponent("text", (node) => node.bindings.add("textContent", FOLDERS[folder()], "empty"))),
);
});
const createButton = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("accent");
button.motif.on("click", () => create());
button.controls.add(motifComponent("text", (node) => node.setText("New note")));
});
box.controls.add(message, createButton);
});
In the function component, notes is the service obtained with FromService and state is the local state set up with reactive; folder(), visible(), create, archive and remove are local functions inside the component function.
The JSX compiles to the following code, and the form without JSX is a readable version of it:
view() {
return _mc("div", {
initializeComponent: sender => {
sender.class.add("placeholder");
const _comp2 = _mc("p", {
initializeComponent: sender => {
sender.bindings.ternary(() => this.notes.count(this.folder), frame => {
frame.navigate(_mc("text", {
initializeComponent: sender => {
sender.setText("Select a note or write a new one.");
}
}, 1));
}, frame => {
frame.navigate(_mc("text", {
initializeComponent: sender => {
sender.bindings.add("textContent", FOLDERS[this.folder], "empty");
},
__childExpr: () => FOLDERS[this.folder].empty
}));
});
}
}, 1);
sender.controls.add(_comp2);
const _comp3 = _mc("button", {
initializeComponent: sender => {
sender.attr.add({
"type": "button"
});
sender.class.add("accent");
sender.motif.on("click", () => this.create());
const _comp = _mc("text", {
initializeComponent: sender => {
sender.setText("New note");
}
}, 1);
sender.controls.add(_comp);
}
}, 1);
sender.controls.add(_comp3);
}
}, 1);
}
return _mc("div", {
initializeComponent: sender => {
sender.class.add("placeholder");
const _comp2 = _mc("p", {
initializeComponent: sender => {
sender.bindings.ternary(() => notes.count(folder()), frame => {
frame.navigate(_mc("text", {
initializeComponent: sender => {
sender.setText("Select a note or write a new one.");
}
}, 1));
}, frame => {
frame.navigate(_mc("text", {
initializeComponent: sender => {
sender.bindings.add("textContent", FOLDERS[folder()], "empty");
},
__childExpr: () => FOLDERS[folder()].empty
}));
});
}
}, 1);
sender.controls.add(_comp2);
const _comp3 = _mc("button", {
initializeComponent: sender => {
sender.attr.add({
"type": "button"
});
sender.class.add("accent");
sender.motif.on("click", () => create());
const _comp = _mc("text", {
initializeComponent: sender => {
sender.setText("New note");
}
}, 1);
sender.controls.add(_comp);
}
}, 1);
sender.controls.add(_comp3);
}
}, 1);
What follows from this:
- The
<div>, the<p>, the<button>and the text inside the button are each a separate component. Each has its owncontrols,class,attr,bindingsandmotifAPI and its own lifecycle; the element's DOM node is in theelementfield. - Attributes, classes and events are calls made on that component's API (
sender.class.add,sender.attr.add,sender.motif.on).initializeComponentis the setup: themotifComponentcall only creates the component, and this function runs once while the component enters the tree and is built (build()), just before the children are built; on a component waiting withx-wait, it does not run until the component is first shown. - The condition in the getter comes down to
bindings.ternary: the two branches are built separately, and when the condition changes, the slot inside the paragraph switches to the other branch; the paragraph itself is not rebuilt. - Once created, the tree remains a live tree of controls: every component can be reached through
parent,controls,siblingsandref, and changed at run time withcontrols.add/controls.remove. - The cost of creating and disposing a component is proportional to what it picks up (bindings, events, child components, services). In a performance review, the unit is the component.
- The
1at the end of the calls, the__childExprkey and the_compnames are the compiler's bookkeeping. You do not write them by hand;_mcis the short name ofmotifComponent.
Plain text is a "text" component: motifComponent("text", (node) => node.setText("…")). For a getter in child position the compiler writes bindings.method; when writing by hand, use bindings.text(fn) for text, while bindings.method is for a getter whose result can also be a component (see Text and reactive expressions).
Elements and nesting
Standard HTML tags are written in lower case and components in upper case. If a component tag is a class it is built with new, if it is a function it is called, and if it is an Options API object it is turned into a component (see Creating Components). Without JSX all three are built with motifComponent(MyComponent, props), and the text and elements written inside the tag go into the childs array. The folder group in the sidebar:
<div class="side__group">
<h2 class="side__label">Folders</h2>
<nav class="nav" aria-label="Folders">
<RouterLink to="/notes" activeClass="active">All notes</RouterLink>
<RouterLink to="/archive" activeClass="active">Archive</RouterLink>
</nav>
</div>
motifComponent("div", (group: ComponentBase) => {
group.class.add("side__group");
const heading = motifComponent("h2", (h2: ComponentBase) => {
h2.class.add("side__label");
h2.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Folders")));
});
const nav = motifComponent("nav", (nav: ComponentBase) => {
nav.class.add("nav");
nav.attr.add({ "aria-label": "Folders" });
nav.controls.add(
motifComponent(RouterLink, { to: "/notes", activeClass: "active", childs: [motifComponent("text", (node: ComponentBase) => node.setText("All notes"))] }),
motifComponent(RouterLink, { to: "/archive", activeClass: "active", childs: [motifComponent("text", (node: ComponentBase) => node.setText("Archive"))] }),
);
});
group.controls.add(heading, nav);
});
<div class="side__group">
<h2 class="side__label">Folders</h2>
<nav class="nav" aria-label="Folders">
<RouterLink to="/notes" activeClass="active">All notes</RouterLink>
<RouterLink to="/archive" activeClass="active">Archive</RouterLink>
</nav>
</div>
motifComponent("div", (group) => {
group.class.add("side__group");
const heading = motifComponent("h2", (h2) => {
h2.class.add("side__label");
h2.controls.add(motifComponent("text", (node) => node.setText("Folders")));
});
const nav = motifComponent("nav", (nav) => {
nav.class.add("nav");
nav.attr.add({ "aria-label": "Folders" });
nav.controls.add(
motifComponent(RouterLink, { to: "/notes", activeClass: "active", childs: [motifComponent("text", (node) => node.setText("All notes"))] }),
motifComponent(RouterLink, { to: "/archive", activeClass: "active", childs: [motifComponent("text", (node) => node.setText("Archive"))] }),
);
});
group.controls.add(heading, nav);
});
Fragment
To return several elements side by side, use a fragment. A fragment is a component without an element: in the DOM it sits between opening and closing comment markers (<!--[--> … <!--]-->), and no attributes or events can be written on the <> tag. The counterpart of <>…</> without JSX is motifFragment, which is also what the compiler produces. The note page returns the three buttons in its toolbar as a fragment:
private actions(note: Note) {
return (
<>
<button type="button" class="button--sm ghost" aria-pressed={() => note.pinned} onclick={() => this.notes.togglePin(note)}>
{() => (note.pinned ? "Pinned" : "Pin")}
</button>
<button type="button" class="button--sm ghost" onclick={() => this.archive(note)}>
{() => (note.archived ? "Restore" : "Archive")}
</button>
<button type="button" class="button--sm danger" onclick={() => (this.state.confirming = true)}>
Delete
</button>
</>
);
}
private actions(note: Note) {
return (
<>
<button type="button" class="button--sm ghost" aria-pressed={() => note.pinned} onclick={() => notes.togglePin(note)}>
{() => (note.pinned ? "Pinned" : "Pin")}
</button>
<button type="button" class="button--sm ghost" onclick={() => archive(note)}>
{() => (note.archived ? "Restore" : "Archive")}
</button>
<button type="button" class="button--sm danger" onclick={() => (state.confirming = true)}>
Delete
</button>
</>
);
}
private actions(note: Note) {
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", "aria-pressed": () => note.pinned });
button.class.add("button--sm ghost");
button.motif.on("click", () => this.notes.togglePin(note));
button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
const archive = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => this.archive(note));
button.bindings.text(() => (note.archived ? "Restore" : "Archive"));
});
const remove = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm danger");
button.motif.on("click", () => (this.state.confirming = true));
button.setText("Delete");
});
return motifFragment({ childs: [pin, archive, remove] });
}
private actions(note: Note) {
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", "aria-pressed": () => note.pinned });
button.class.add("button--sm ghost");
button.motif.on("click", () => notes.togglePin(note));
button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
const archiveButton = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => archive(note));
button.bindings.text(() => (note.archived ? "Restore" : "Archive"));
});
const remove = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm danger");
button.motif.on("click", () => (state.confirming = true));
button.setText("Delete");
});
return motifFragment({ childs: [pin, archiveButton, remove] });
}
actions(note) {
return (
<>
<button type="button" class="button--sm ghost" aria-pressed={() => note.pinned} onclick={() => this.notes.togglePin(note)}>
{() => (note.pinned ? "Pinned" : "Pin")}
</button>
<button type="button" class="button--sm ghost" onclick={() => this.archive(note)}>
{() => (note.archived ? "Restore" : "Archive")}
</button>
<button type="button" class="button--sm danger" onclick={() => (this.state.confirming = true)}>
Delete
</button>
</>
);
}
actions(note) {
return (
<>
<button type="button" class="button--sm ghost" aria-pressed={() => note.pinned} onclick={() => notes.togglePin(note)}>
{() => (note.pinned ? "Pinned" : "Pin")}
</button>
<button type="button" class="button--sm ghost" onclick={() => archive(note)}>
{() => (note.archived ? "Restore" : "Archive")}
</button>
<button type="button" class="button--sm danger" onclick={() => (state.confirming = true)}>
Delete
</button>
</>
);
}
actions(note) {
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button", "aria-pressed": () => note.pinned });
button.class.add("button--sm ghost");
button.motif.on("click", () => this.notes.togglePin(note));
button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
const archive = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => this.archive(note));
button.bindings.text(() => (note.archived ? "Restore" : "Archive"));
});
const remove = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm danger");
button.motif.on("click", () => (this.state.confirming = true));
button.setText("Delete");
});
return motifFragment({ childs: [pin, archive, remove] });
}
actions(note) {
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button", "aria-pressed": () => note.pinned });
button.class.add("button--sm ghost");
button.motif.on("click", () => notes.togglePin(note));
button.bindings.text(() => (note.pinned ? "Pinned" : "Pin"));
});
const archiveButton = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => archive(note));
button.bindings.text(() => (note.archived ? "Restore" : "Archive"));
});
const remove = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm danger");
button.motif.on("click", () => (state.confirming = true));
button.setText("Delete");
});
return motifFragment({ childs: [pin, archiveButton, remove] });
}
The same fragment can also be written as the value of a branch or a slot: motifFragment({ initializeComponent: (f) => f.controls.add(a, b) }). The view() of an Options API component can return an array as well (see Options API components). The note page itself declares no element, so it is a fragment too; the content returned by view() sits between its markers (see Root element).
Text and reactive expressions
Expressions are written inside curly braces {...}. The form of the expression decides whether it is reactive. The call each form comes down to, and its counterpart without JSX:
| Form | Behaviour | Without JSX |
|---|---|---|
{"Notes"} |
Static; written once. | motifComponent("text", (node) => node.setText("Notes")) |
{count} (a local variable) |
A guarded getter: if it holds a string or number, it is written once; if it holds a function, it is called and stays live; if it holds a component (or an array of components), it is placed in controls; null/undefined becomes empty text. |
Depending on the value's type, node.setText(String(count)), span.bindings.text(fn) or controls.add(component) |
{fmt(note.updatedAt)} |
A plain call; evaluated once, during setup. | node.setText(fmt(note.updatedAt)) |
{note.title} |
A one-way binding to that field; only that field is tracked. | node.bindings.add("textContent", note, "title") |
{() => note.title.trim() \|\| "Untitled"} |
A getter; every reactive field read inside it is tracked, and it is recomputed when one of them changes. | span.bindings.text(() => note.title.trim() \|\| "Untitled") |
{`${note.title} · ${fmt(note.updatedAt)}`} |
A template string is wrapped in a getter; it is live. | span.bindings.text(() => `${note.title} · ${fmt(note.updatedAt)}`) |
{note.title ?? "Untitled"}, {a \|\| b} |
Live; the left side if it is there (??: not null/undefined, \|\|: truthy), otherwise the right side. The right side can be JSX too. |
span.bindings.text(() => note.title ?? "Untitled") |
{this.badge(note)} (a member call) |
If the result is a component, a Frame slot (evaluated again on a reactive change); if it is a string, live text. |
li.bindings.method(() => this.badge(note)) |
{cond && <X/>}, {cond ? <A/> : <B/>}, {items.map(…)} |
Condition and list bindings; see Conditional Rendering and Lists. | bindings.when, bindings.ternary, bindings.list |
{this.childs}, {props.childs}, {[a, b]}, {instance} |
Places component instances. | controls.add(...) |
Fixed text
Literal text is written once. So is a plain local variable ({count}, when count is a number): it contains no reactive read, so it is not updated.
<h2>Notes</h2>
<p class="list__empty">{"No notes yet."}</p>
const heading = motifComponent("h2", (h2: ComponentBase) => h2.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Notes"))));
const empty = motifComponent("p", (p: ComponentBase) => {
p.class.add("list__empty");
p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("No notes yet.")));
});
<h2>Notes</h2>
<p class="list__empty">{"No notes yet."}</p>
const heading = motifComponent("h2", (h2) => h2.controls.add(motifComponent("text", (node) => node.setText("Notes"))));
const empty = motifComponent("p", (p) => {
p.class.add("list__empty");
p.controls.add(motifComponent("text", (node) => node.setText("No notes yet.")));
});
A reactive model field
If you write a field of a reactive object directly, a one-way binding is set up to that field. When the field changes, only that text node is updated:
<span>{note.title}</span>
const title = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))));
<span>{note.title}</span>
const title = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))));
A reactive expression with a getter
To keep a computed value live, put the expression in an arrow function; the reactive reads inside the function are tracked, and the text is recomputed when one of them changes. The title and first line of a note row:
<span>{() => note.title.trim() || "Untitled"}</span>
<p>{() => note.body.split("\n")[0] || "No text"}</p>
const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
const preview = motifComponent("p", (p: ComponentBase) => p.bindings.text(() => note.body.split("\n")[0] || "No text"));
<span>{() => note.title.trim() || "Untitled"}</span>
<p>{() => note.body.split("\n")[0] || "No text"}</p>
const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
const preview = motifComponent("p", (p) => p.bindings.text(() => note.body.split("\n")[0] || "No text"));
Rule: If you want an expression's value to change over time and show up in the DOM, write either a reactive field directly (
note.title) or a getter (() => …). With a path written directly, only the last field is tracked and the object in front of it is read once, during setup:{state.note.title}updates when that note's title changes, but whenstate.noteis switched to another note it stays on the old one. To track the whole path, write a getter ({() => state.note?.title}); a path containing?.({state.note?.title}) is wrapped in a getter too and stays live. A plain local variable stays static:{label}written withconst label = state.items.length + " notes"does not change even when a new note is added, while{() => state.items.length + " notes"}does.
Attributes
Attributes take a value or a function that returns a value. A function is a live binding, and a literal value is written once. In JSX the compiler wraps member access (note.pinned), operations (!state.confirming, a + b), template strings and ?. chains in a getter on its own, so these stay live as well (details below). From the note page:
<input class="editor__title" placeholder="Untitled" value={() => note.title} />
<a rel="router" href={() => `/${this.folder}`}>Back</a>
<button type="button" disabled={() => this.state.confirming}>Archive</button>
<span aria-pressed={note.pinned} data-id={note.id}>…</span>
<input class="editor__title" placeholder="Untitled" value={() => note.title} />
<a rel="router" href={() => `/${folder()}`}>Back</a>
<button type="button" disabled={() => state.confirming}>Archive</button>
<span aria-pressed={note.pinned} data-id={note.id}>…</span>
const title = motifComponent("input", (input: ComponentBase) => {
input.class.add("editor__title");
input.attr.add({ placeholder: "Untitled" });
input.bindings.add("value", () => note.title);
});
const back = motifComponent("a", (a: ComponentBase) => {
a.attr.add({ rel: "router", href: () => `/${this.folder}` });
a.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Back")));
});
const archive = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", disabled: () => this.state.confirming });
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
});
const flag = motifComponent("span", (span: ComponentBase) => span.attr.add({ "aria-pressed": () => note.pinned, "data-id": note.id }));
const title = motifComponent("input", (input: ComponentBase) => {
input.class.add("editor__title");
input.attr.add({ placeholder: "Untitled" });
input.bindings.add("value", () => note.title);
});
const back = motifComponent("a", (a: ComponentBase) => {
a.attr.add({ rel: "router", href: () => `/${folder()}` });
a.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Back")));
});
const archive = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", disabled: () => state.confirming });
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
});
const flag = motifComponent("span", (span: ComponentBase) => span.attr.add({ "aria-pressed": () => note.pinned, "data-id": note.id }));
<input class="editor__title" placeholder="Untitled" value={() => note.title} />
<a rel="router" href={() => `/${this.folder}`}>Back</a>
<button type="button" disabled={() => this.state.confirming}>Archive</button>
<span aria-pressed={note.pinned} data-id={note.id}>…</span>
<input class="editor__title" placeholder="Untitled" value={() => note.title} />
<a rel="router" href={() => `/${folder()}`}>Back</a>
<button type="button" disabled={() => state.confirming}>Archive</button>
<span aria-pressed={note.pinned} data-id={note.id}>…</span>
const title = motifComponent("input", (input) => {
input.class.add("editor__title");
input.attr.add({ placeholder: "Untitled" });
input.bindings.add("value", () => note.title);
});
const back = motifComponent("a", (a) => {
a.attr.add({ rel: "router", href: () => `/${this.folder}` });
a.controls.add(motifComponent("text", (node) => node.setText("Back")));
});
const archive = motifComponent("button", (button) => {
button.attr.add({ type: "button", disabled: () => this.state.confirming });
button.controls.add(motifComponent("text", (node) => node.setText("Archive")));
});
const flag = motifComponent("span", (span) => span.attr.add({ "aria-pressed": () => note.pinned, "data-id": note.id }));
const title = motifComponent("input", (input) => {
input.class.add("editor__title");
input.attr.add({ placeholder: "Untitled" });
input.bindings.add("value", () => note.title);
});
const back = motifComponent("a", (a) => {
a.attr.add({ rel: "router", href: () => `/${folder()}` });
a.controls.add(motifComponent("text", (node) => node.setText("Back")));
});
const archive = motifComponent("button", (button) => {
button.attr.add({ type: "button", disabled: () => state.confirming });
button.controls.add(motifComponent("text", (node) => node.setText("Archive")));
});
const flag = motifComponent("span", (span) => span.attr.add({ "aria-pressed": () => note.pinned, "data-id": note.id }));
value, checked and selected are property bindings (bindings.add("value", getter)); the others are written with attr.add.
On a plain DOM tag, literal values go to attr.add as they are: numbers, negative numbers, true/false and null (arrays and new X() also go through as values; for element methods they become arguments). false, null and undefined do not write the attribute (and remove it if present); true and the valueless form write the attribute with an empty value (""); numbers are turned into strings:
| JSX | On the element |
|---|---|
<button disabled={false} /> |
no disabled |
<button disabled />, <li hidden={true} /> |
disabled="", hidden="" |
<li tabindex={0} /> |
tabindex="0" |
<li title={null} /> |
no title |
On aria-* and data-* attributes a boolean value is written as text: aria-selected={false} → aria-selected="false", data-pinned={true} → data-pinned="true"; null/undefined still remove the attribute. An object or array value is turned into text as a single attribute value (data-ids={[1, 2]} → data-ids="1,2").
An expression that reads a variable (a + b, !state.confirming, note.title) is wrapped in a getter and stays live: aria-expanded={!state.confirming} updates when confirming changes. A lone variable (attr={v}) is passed as it is, and it is live only if it holds a function. A plain function call (data-updated={fmt(note.updatedAt)}) is evaluated once; to keep it live, write () => fmt(note.updatedAt).
Security: attributes written on a tag and
attr.addvalues are written without filtering. If a value coming from the user is bound to a URL attribute such ashref,src,actionorformaction, a value starting withjavascript:runs code on click. Before binding such values, check them against the allowed schemes (https:,mailto:, a relative address) and turn anything that does not fit into#.attr.add({ innerHTML })also writes HTML as it is, likex-html. Only spread skipsinnerHTML,srcdocandjavascript:URLs that come from outside on its own.
Element methods
On a plain DOM tag, a prop whose name is one of the element methods in the table below does not write an attribute; it calls that method. Method names that are not in the table (setCustomValidity, stepUp …) are not called and are written as plain attributes. If the value is a getter (or a reactive field), it is applied again on every change, and the call is made one microtask later. Without JSX, the same name is given to attr.add, and the runtime is what turns it into a call. The delete confirmation opens and closes in a dialog, and when the confirmation opens, focus moves to the "Delete" button:
<dialog class="confirm" showModal={() => this.state.confirming}>
<button type="button" class="danger" focus={() => this.state.confirming} onclick={() => this.remove(note)}>Delete</button>
</dialog>
<dialog class="confirm" showModal={() => state.confirming}>
<button type="button" class="danger" focus={() => state.confirming} onclick={() => remove(note)}>Delete</button>
</dialog>
motifComponent("dialog", (dialog: ComponentBase) => {
dialog.class.add("confirm");
dialog.attr.add({ showModal: () => this.state.confirming });
dialog.controls.add(motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", focus: () => this.state.confirming });
button.class.add("danger");
button.motif.on("click", () => this.remove(note));
button.setText("Delete");
}));
});
motifComponent("dialog", (dialog: ComponentBase) => {
dialog.class.add("confirm");
dialog.attr.add({ showModal: () => state.confirming });
dialog.controls.add(motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", focus: () => state.confirming });
button.class.add("danger");
button.motif.on("click", () => remove(note));
button.setText("Delete");
}));
});
<dialog class="confirm" showModal={() => this.state.confirming}>
<button type="button" class="danger" focus={() => this.state.confirming} onclick={() => this.remove(note)}>Delete</button>
</dialog>
<dialog class="confirm" showModal={() => state.confirming}>
<button type="button" class="danger" focus={() => state.confirming} onclick={() => remove(note)}>Delete</button>
</dialog>
motifComponent("dialog", (dialog) => {
dialog.class.add("confirm");
dialog.attr.add({ showModal: () => this.state.confirming });
dialog.controls.add(motifComponent("button", (button) => {
button.attr.add({ type: "button", focus: () => this.state.confirming });
button.class.add("danger");
button.motif.on("click", () => this.remove(note));
button.setText("Delete");
}));
});
motifComponent("dialog", (dialog) => {
dialog.class.add("confirm");
dialog.attr.add({ showModal: () => state.confirming });
dialog.controls.add(motifComponent("button", (button) => {
button.attr.add({ type: "button", focus: () => state.confirming });
button.class.add("danger");
button.motif.on("click", () => remove(note));
button.setText("Delete");
}));
});
| Prop | true |
false |
Any other value |
|---|---|---|---|
focus |
focus() |
blur() |
an object → focus(object), anything else → focus() |
show, showModal |
calls it | close() |
calls it without arguments on a truthy value |
showPopover |
calls it | hidePopover() |
calls it without arguments on a truthy value |
togglePopover |
togglePopover(true) |
togglePopover(false) |
togglePopover() on a truthy value |
play |
play() |
pause() |
truthy → play(), falsy → pause() |
select |
select() |
clears the selection | truthy → select(), falsy → clears the selection |
requestFullscreen, requestPointerLock |
calls it | exits | truthy → calls it, falsy → exits |
close, hidePopover, requestSubmit, checkValidity, reportValidity, showPicker, load |
calls it | — | method(value) on a truthy value — e.g. close('cancel'), requestSubmit(button) |
blur, click, pause, submit, reset, exitFullscreen, exitPointerLock, requestPictureInPicture, exitPictureInPicture |
calls it | — | calls it without arguments on a truthy value |
scrollIntoView |
scrollIntoView() |
scrollIntoView(false) |
null/undefined → scrollIntoView(), anything else scrollIntoView(value) |
scrollTo, scrollBy |
— | — | only an object or an array: method(value) |
setSelectionRange, setRangeText, setPointerCapture, releasePointerCapture, fastSeek |
method(true) |
— | method(value) for anything except null/undefined |
An array value is spread into arguments (setSelectionRange={[0, 5]} → setSelectionRange(0, 5)), and an object value is passed as a single argument (scrollTo={{ top: 0 }}, focus={{ preventScroll: true }}); in neither case is an attribute written. focus calls blur() only for exactly false; undefined, null, 0 and '' mean focus() too, so if you are going to write focus={() => state.confirming}, start the field at false. exitFullscreen, exitPointerLock and exitPictureInPicture are called on document. If the element does not have that method, the value is written as a plain attribute. On a component tag (<NoteFields focus={…}/>) these names are ordinary props.
Spread — `
An object spread onto a plain DOM element is applied as if its attributes had been written directly. The counterpart without JSX is to pass the object as the second argument of motifComponent; the runtime is what applies the DOM props. TextField spreads everything it receives onto the input:
type TextFieldProps = JSX.IntrinsicElements["input"];
export function TextField(props: TextFieldProps) {
return <input {...props} />;
}
import { motifComponent } from "@motifx/core";
type TextFieldProps = JSX.IntrinsicElements["input"];
export function TextField(props: TextFieldProps) {
return motifComponent("input", { ...props });
}
export function TextField(props) {
return <input {...props} />;
}
import { motifComponent } from "@motifx/core";
export function TextField(props) {
return motifComponent("input", { ...props });
}
The note page builds its title field with it:
<TextField class="editor__title" aria-label="Title" value={() => note.title} oninput={(e) => (note.title = (e.target as HTMLInputElement).value)} />
motifComponent(TextField, { class: "editor__title", "aria-label": "Title", value: () => note.title, oninput: (e: Event) => (note.title = (e.target as HTMLInputElement).value) });
<TextField class="editor__title" aria-label="Title" value={() => note.title} oninput={(e) => (note.title = e.target.value)} />
motifComponent(TextField, { class: "editor__title", "aria-label": "Title", value: () => note.title, oninput: (e) => (note.title = e.target.value) });
| Key | Applied as |
|---|---|
class / className |
class.add — combined with a class written directly |
style |
style() — a string, an object or a getter |
value / checked / selected |
a property binding (bindings.add) |
on* (onclick, onClick, oninput:once) |
a DOM event |
every other key (id, title, aria-*, data-*, viewBox…) |
an attribute (attr.add) |
innerHTML / srcdoc |
not written (an MJX124 warning in development mode) |
href / src / action / formaction / xlink:href whose value starts with javascript: |
not written; for a getter, the current value is removed (an MJX125 warning in development mode) |
These two paths are closed because a spread object often carries data that comes from outside. For HTML you trust, use x-html or a srcdoc written directly on the tag (<iframe srcdoc={html}>), and for a javascript: link written on purpose, an attribute written directly on the tag (<a href="javascript:void 0">); these are not filtered.
Getter (() => …) values are live. Functions that expect a parameter (renderItem: (x) => …) do not count as attributes and are skipped. ref, key, options, transition, initializeComponent, childs, x-* and the lifecycle props go to the framework.
Order: the spread is applied in the constructor, and the attributes written directly on the tag in the initializeComponent the compiler produces, so for the same attribute the one written directly wins (<input {...p} id="title"/>), while class is merged. This holds even if the value in the spread is a getter: the direct form becomes the owner of that attribute, the getter does not write to that attribute again, and the spread's other getters stay live. The same rule applies to value, checked and selected, and to the keys of style: a directly written style={{ color: "blue" }} takes over only the color key of the spread's style getter, and the getter's other keys stay live; a directly written style="…" string takes over the whole attribute.
On a component tag, a known DOM event name (oninput, onclick) is attached to the component's element as a listener and never enters props. TextField above is a function, so the tag's oninput is attached to the input it returns. If a class without an element of its own writes <input {...this.props} /> in view(), props has no oninput; a class with an element, and an Options component whose el is a tag name (el: "input") or a ready DOM node, take only the common attributes and events below from the tag, and the others such as value stay in this.props.
Do not spread plain values that do not belong to an element (
items: [...]) onto a DOM element; they are written as attributes.
On a component tag: common attributes fall through to the component's element (attribute fallthrough)
When you write <CountBadge class="nav__count" id="count-notes" count={…}/>, only the common attributes — class/className (merged with the component's own class), style, id, tabindex, role, aria-*, data-* — are applied automatically to the component's own element, and getters are live. The same attribute written by the component itself in its own onConfigured/initializeComponent/view wins (the fallthrough happens in the constructor, before those). Data and callback props (count, note, title, onSave, disabled…) are not written to the element — title is left off the list on purpose, because it is a common data prop name in components. All props, the ones that fall through included, stay readable in the component's props (this.props in a class, the parameter in a function, the factory's parameter in Options). In TypeScript these attributes and the DOM event props are accepted on every component tag, also on a component whose constructor takes its own prop type; if the component defines a prop with the same name, its own type is used.
The folder link in the sidebar shows the number of notes in a small badge:
import { Component, read, type Bind } from "@motifx/core";
export class CountBadge extends Component<HTMLSpanElement, { count: Bind<number> }> {
override onConfigured() {
this.class.add("badge");
}
override view() {
return <>{() => String(read(this.props.count))}</>;
}
}
import { Component, ComponentBase, motifComponent, read, type Bind } from "@motifx/core";
export class CountBadge extends Component<HTMLSpanElement, { count: Bind<number> }> {
static override elementTag = "span";
override onConfigured() {
this.class.add("badge");
}
override view() {
return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => String(read(this.props.count))));
}
}
import { read, type Bind } from "@motifx/core";
export function CountBadge(props: { count: Bind<number> }) {
return <span class="badge">{() => String(read(props.count))}</span>;
}
import { ComponentBase, motifComponent, read, type Bind } from "@motifx/core";
export function CountBadge(props: { count: Bind<number> }) {
return motifComponent("span", (span: ComponentBase) => {
span.class.add("badge");
span.bindings.text(() => String(read(props.count)));
});
}
import { motifOptions, read, type Bind } from "@motifx/core";
export const CountBadge = (props: { count: Bind<number> }) => motifOptions({
el: "span",
onConfigured() {
this.class.add("badge");
},
view() {
return <>{() => String(read(props.count))}</>;
},
});
import { ComponentBase, motifComponent, motifOptions, read, type Bind } from "@motifx/core";
export const CountBadge = (props: { count: Bind<number> }) => motifOptions({
el: "span",
onConfigured() {
this.class.add("badge");
},
view() {
return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => String(read(props.count))));
},
});
import { Component, read } from "@motifx/core";
export class CountBadge extends Component {
static elementTag = "span";
onConfigured() {
this.class.add("badge");
}
view() {
return <>{() => String(read(this.props.count))}</>;
}
}
import { Component, motifComponent, read } from "@motifx/core";
export class CountBadge extends Component {
static elementTag = "span";
onConfigured() {
this.class.add("badge");
}
view() {
return motifComponent("text", (node) => node.bindings.text(() => String(read(this.props.count))));
}
}
import { read } from "@motifx/core";
export function CountBadge(props) {
return <span class="badge">{() => String(read(props.count))}</span>;
}
import { motifComponent, read } from "@motifx/core";
export function CountBadge(props) {
return motifComponent("span", (span) => {
span.class.add("badge");
span.bindings.text(() => String(read(props.count)));
});
}
import { motifOptions, read } from "@motifx/core";
export const CountBadge = (props) => motifOptions({
el: "span",
onConfigured() {
this.class.add("badge");
},
view() {
return <>{() => String(read(props.count))}</>;
},
});
import { motifComponent, motifOptions, read } from "@motifx/core";
export const CountBadge = (props) => motifOptions({
el: "span",
onConfigured() {
this.class.add("badge");
},
view() {
return motifComponent("text", (node) => node.bindings.text(() => String(read(props.count))));
},
});
<CountBadge class="nav__count" id="count-notes" count={() => this.notes.count("notes")} />
<CountBadge class="nav__count" id="count-notes" count={() => notes.count("notes")} />
motifComponent(CountBadge, { class: "nav__count", id: "count-notes", count: () => this.notes.count("notes") });
motifComponent(CountBadge, { class: "nav__count", id: "count-notes", count: () => notes.count("notes") });
<CountBadge class="nav__count" id="count-notes" count={() => this.notes.count("notes")} />
<CountBadge class="nav__count" id="count-notes" count={() => notes.count("notes")} />
motifComponent(CountBadge, { class: "nav__count", id: "count-notes", count: () => this.notes.count("notes") });
motifComponent(CountBadge, { class: "nav__count", id: "count-notes", count: () => notes.count("notes") });
The result is the same in all three forms: <span class="nav__count badge" id="count-notes"> (nav__count from the tag is added in the constructor, and the component's own badge during setup). In the class component the element comes from the generic (Component<HTMLSpanElement>), in the function from the returned root, and in Options from el. If the root of the function component is <span {...props}/>, the same value is not applied a second time. On a component without an element (a fragment) these attributes are quietly ignored, while DOM events listen on the top-level elements of the content (see Root element).
class and className
Both are supported. The value can be a string, an array, an object or a getter function. The classes of a note row:
<a class="row" />
<a class={() => `row${open() ? " active" : ""}`} />
<a class={`row ${note.title.trim() ? "" : "untitled"}`} />
const fixed = motifComponent("a", (row: ComponentBase) => row.class.add("row"));
const active = motifComponent("a", (row: ComponentBase) => row.class.add(() => `row${open() ? " active" : ""}`));
const untitled = motifComponent("a", (row: ComponentBase) => row.class.add(() => `row ${note.title.trim() ? "" : "untitled"}`));
<a class="row" />
<a class={() => `row${open() ? " active" : ""}`} />
<a class={`row ${note.title.trim() ? "" : "untitled"}`} />
const fixed = motifComponent("a", (row) => row.class.add("row"));
const active = motifComponent("a", (row) => row.class.add(() => `row${open() ? " active" : ""}`));
const untitled = motifComponent("a", (row) => row.class.add(() => `row ${note.title.trim() ? "" : "untitled"}`));
The first line is a fixed class, the second is a getter, and the template string in the third is wrapped in a getter and stays live.
In an object the key is the class name and the value is the condition: if it is truthy the class is added, otherwise it is removed. On a plain DOM tag, constants such as true/false are applied once, and every other value is wrapped in a getter and is live. An object can also be written inside an array; each item in the array is handled as if it had been written on its own. In an object passed to class.add, on the other hand, a value that is not a function is read once, at the time of the call. The same is true on a component tag: there the compiler wraps only the ternary, and the class value is applied to the component's element with class.add, so the condition in <CountBadge class={{ empty: count === 0 }} />, a template string and class={note.cls} are evaluated once, while the tag is being built. Without JSX and on a component tag, you pass a live condition as a function yourself ({ active: () => open() }, class={() => …}):
<a class={{ row: true, active: open(), untitled: !note.title.trim() }} />
<a class={["row", { active: () => open() }]} />
const flags = motifComponent("a", (row: ComponentBase) => row.class.add({ row: true, active: () => open(), untitled: () => !note.title.trim() }));
const mixed = motifComponent("a", (row: ComponentBase) => row.class.add(["row", { active: () => open() }]));
<a class={{ row: true, active: open(), untitled: !note.title.trim() }} />
<a class={["row", { active: () => open() }]} />
const flags = motifComponent("a", (row) => row.class.add({ row: true, active: () => open(), untitled: () => !note.title.trim() }));
const mixed = motifComponent("a", (row) => row.class.add(["row", { active: () => open() }]));
After setup, classes are managed with the component's class helper: class.add, class.remove, class.has (this.class in a class and in Options, the class of the root component in a function component) (see Appearance).
style
style takes a string, an object or a getter:
<a style="opacity: 0.6;" />
<a style={{ borderLeft: "3px solid var(--accent)" }} />
<a style={() => ({ opacity: note.archived ? 0.6 : 1 })} />
const dimmed = motifComponent("a", (row: ComponentBase) => row.style("opacity: 0.6;"));
const marked = motifComponent("a", (row: ComponentBase) => row.style({ borderLeft: "3px solid var(--accent)" }));
const live = motifComponent("a", (row: ComponentBase) => row.style(() => ({ opacity: note.archived ? 0.6 : 1 })));
<a style="opacity: 0.6;" />
<a style={{ borderLeft: "3px solid var(--accent)" }} />
<a style={() => ({ opacity: note.archived ? 0.6 : 1 })} />
const dimmed = motifComponent("a", (row) => row.style("opacity: 0.6;"));
const marked = motifComponent("a", (row) => row.style({ borderLeft: "3px solid var(--accent)" }));
const live = motifComponent("a", (row) => row.style(() => ({ opacity: note.archived ? 0.6 : 1 })));
A getter is live: it is applied again when its dependencies change, and keys that are missing from the getter's new result are cleared (going from {color} to {opacity} removes color). If the getter returns a string, cssText changes as a whole (the other inline styles on that element go too). x-style={...} is exactly the same as style. style(fn) keeps a single watcher; calling it again stops the previous one. Keys written with an object after the getter (style({ color: "blue" })) are taken away from the getter, which does not write to them again; a string written after the getter stops the getter. CSS variables starting with -- are written in an object or a getter like any other key (style={{ "--accent": note.color }}); a variable whose value is null or an empty string is removed.
Placing ready components as children
You can collect pieces of JSX in a variable and put it in child position; if the value is a component or an array of components, it is placed in controls without being turned into text. If the value is not a component (a string, a number…), the usual reactive text binding is set up, so you do not need to say separately whether a prop is a component or data. Without JSX you make this distinction yourself: a component is placed with controls.add, and text with a "text" component.
JSX passed to a prop can also be written without curly braces: toolbar=<div class="toolbar">…</div> compiles the same as toolbar={<div class="toolbar">…</div>}.
Pane, the frame shared by the pages, takes a toolbar component at the top and a title text under it:
import { Component, type ComponentBase } from "@motifx/core";
export class Pane extends Component<HTMLDivElement, { toolbar?: ComponentBase; title: string }> {
override onConfigured() {
this.class.add("pane");
}
override view() {
return (
<>
{this.props.toolbar}
<h2>{this.props.title}</h2>
</>
);
}
}
import { Component, ComponentBase, motifComponent, motifFragment } from "@motifx/core";
export class Pane extends Component<HTMLDivElement, { toolbar?: ComponentBase; title: string }> {
static override elementTag = "div";
override onConfigured() {
this.class.add("pane");
}
override view() {
const heading = motifComponent("h2", (h2: ComponentBase) => h2.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", this.props, "title"))));
return motifFragment({ childs: this.props.toolbar ? [this.props.toolbar, heading] : [heading] });
}
}
import type { ComponentBase } from "@motifx/core";
export function Pane(props: { toolbar?: ComponentBase; title: string }) {
return (
<div class="pane">
{props.toolbar}
<h2>{props.title}</h2>
</div>
);
}
import { ComponentBase, motifComponent } from "@motifx/core";
export function Pane(props: { toolbar?: ComponentBase; title: string }) {
return motifComponent("div", (pane: ComponentBase) => {
pane.class.add("pane");
if (props.toolbar) pane.controls.add(props.toolbar);
pane.controls.add(motifComponent("h2", (h2: ComponentBase) => h2.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", props, "title")))));
});
}
import { motifOptions, type ComponentBase } from "@motifx/core";
export const Pane = (props: { toolbar?: ComponentBase; title: string }) => motifOptions({
el: "div",
onConfigured() {
this.class.add("pane");
},
view() {
return (
<>
{props.toolbar}
<h2>{props.title}</h2>
</>
);
},
});
import { ComponentBase, motifComponent, motifFragment, motifOptions } from "@motifx/core";
export const Pane = (props: { toolbar?: ComponentBase; title: string }) => motifOptions({
el: "div",
onConfigured() {
this.class.add("pane");
},
view() {
const heading = motifComponent("h2", (h2: ComponentBase) => h2.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", props, "title"))));
return motifFragment({ childs: props.toolbar ? [props.toolbar, heading] : [heading] });
},
});
import { Component } from "@motifx/core";
export class Pane extends Component {
static elementTag = "div";
onConfigured() {
this.class.add("pane");
}
view() {
return (
<>
{this.props.toolbar}
<h2>{this.props.title}</h2>
</>
);
}
}
import { Component, motifComponent, motifFragment } from "@motifx/core";
export class Pane extends Component {
static elementTag = "div";
onConfigured() {
this.class.add("pane");
}
view() {
const heading = motifComponent("h2", (h2) => h2.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", this.props, "title"))));
return motifFragment({ childs: this.props.toolbar ? [this.props.toolbar, heading] : [heading] });
}
}
export function Pane(props) {
return (
<div class="pane">
{props.toolbar}
<h2>{props.title}</h2>
</div>
);
}
import { motifComponent } from "@motifx/core";
export function Pane(props) {
return motifComponent("div", (pane) => {
pane.class.add("pane");
if (props.toolbar) pane.controls.add(props.toolbar);
pane.controls.add(motifComponent("h2", (h2) => h2.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", props, "title")))));
});
}
import { motifOptions } from "@motifx/core";
export const Pane = (props) => motifOptions({
el: "div",
onConfigured() {
this.class.add("pane");
},
view() {
return (
<>
{props.toolbar}
<h2>{props.title}</h2>
</>
);
},
});
import { motifComponent, motifFragment, motifOptions } from "@motifx/core";
export const Pane = (props) => motifOptions({
el: "div",
onConfigured() {
this.class.add("pane");
},
view() {
const heading = motifComponent("h2", (h2) => h2.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", props, "title"))));
return motifFragment({ childs: props.toolbar ? [props.toolbar, heading] : [heading] });
},
});
<Pane toolbar={<div class="toolbar"><span class="toolbar__spacer"></span></div>} title="Settings" />
const toolbar = motifComponent("div", (bar: ComponentBase) => {
bar.class.add("toolbar");
bar.controls.add(motifComponent("span", (span: ComponentBase) => span.class.add("toolbar__spacer")));
});
motifComponent(Pane, { toolbar, title: "Settings" });
<Pane toolbar={<div class="toolbar"><span class="toolbar__spacer"></span></div>} title="Settings" />
const toolbar = motifComponent("div", (bar) => {
bar.class.add("toolbar");
bar.controls.add(motifComponent("span", (span) => span.class.add("toolbar__spacer")));
});
motifComponent(Pane, { toolbar, title: "Settings" });
If the template will change later, write a getter
The placement decision is made at setup time and only once: if the prop holds a component while it is being set up, it is placed, and the DOM is not updated even if it changes later (the placed component's own bindings are live as usual). Props are a plain object taken once, while the tag is being built: a plain expression such as toolbar={toolbar} does not carry a template assigned later to the child, and the getter {() => props.toolbar} does not track it either, because there is no reactive read inside it.
If the template starts out null and arrives later, or is swapped while running, the parent passes it with a ternary or a getter (toolbar={state.confirming ? <div class="toolbar">…</div> : null}; a ternary in a prop is always wrapped in a getter). The child declares the prop as Bind<ComponentBase | null> and reads it with read() inside a getter; this sets up a slot (Frame) and places the getter's current result on every change:
<div class="pane">{() => read(props.toolbar)}</div>
const pane = motifComponent("div", (pane: ComponentBase) => {
pane.class.add("pane");
pane.bindings.method(() => read(props.toolbar));
});
<div class="pane">{() => read(props.toolbar)}</div>
const pane = motifComponent("div", (pane) => {
pane.class.add("pane");
pane.bindings.method(() => read(props.toolbar));
});
{() => props.toolbar} written without read() does not track the getter prop: it places only the first template, and if the template starts out null it shows nothing. If a component ends up in a text binding later even though you did not write a getter, the framework writes nothing to the DOM, and in development mode it suggests the getter form with an MJX204 warning.
Raw HTML — x-html
To set an element's innerHTML reactively, use x-html; its counterpart without JSX is bindings.html. renderMarkdown in the example is a function from a Markdown library that sanitizes its output:
<article class="preview" x-html={() => renderMarkdown(note.body)}></article>
const preview = motifComponent("article", (article: ComponentBase) => {
article.class.add("preview");
article.bindings.html(() => renderMarkdown(note.body));
});
<article class="preview" x-html={() => renderMarkdown(note.body)}></article>
const preview = motifComponent("article", (article) => {
article.class.add("preview");
article.bindings.html(() => renderMarkdown(note.body));
});
Security:
x-htmlinjects HTML that has not been escaped. Use it only with content you trust, or with a converter that sanitizes its output.
Events
on... props are attached to DOM events; the counterpart without JSX is motif.on. A handler is written with the (sender, event) signature or with just (event); MotifJS looks at the number of parameters:
<button type="button" onclick={() => this.notes.togglePin(note)}>Pin</button>
<input class="editor__title" oninput={(e) => (note.title = (e.target as HTMLInputElement).value)} />
<a rel="router" href={`/${this.folder}`} onclick={(sender, e) => sender.class.add("visited")}>Back</a>
<form class="confirm" onsubmit:prevent={() => this.remove(note)}>…</form>
<button type="button" onclick={() => notes.togglePin(note)}>Pin</button>
<input class="editor__title" oninput={(e) => (note.title = (e.target as HTMLInputElement).value)} />
<a rel="router" href={`/${folder()}`} onclick={(sender, e) => sender.class.add("visited")}>Back</a>
<form class="confirm" onsubmit:prevent={() => remove(note)}>…</form>
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.notes.togglePin(note));
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Pin")));
});
const title = motifComponent("input", (input: ComponentBase) => {
input.class.add("editor__title");
input.motif.on("input", (_sender: ComponentBase, e: Event) => (note.title = (e.target as HTMLInputElement).value));
});
const back = motifComponent("a", (a: ComponentBase) => {
a.attr.add({ rel: "router", href: () => `/${this.folder}` });
a.motif.on("click", (sender: ComponentBase, _e: Event) => sender.class.add("visited"));
a.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Back")));
});
const confirm = motifComponent("form", (form: ComponentBase) => {
form.class.add("confirm");
form.motif.on("submit:prevent", () => this.remove(note));
});
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => notes.togglePin(note));
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Pin")));
});
const title = motifComponent("input", (input: ComponentBase) => {
input.class.add("editor__title");
input.motif.on("input", (_sender: ComponentBase, e: Event) => (note.title = (e.target as HTMLInputElement).value));
});
const back = motifComponent("a", (a: ComponentBase) => {
a.attr.add({ rel: "router", href: () => `/${folder()}` });
a.motif.on("click", (sender: ComponentBase, _e: Event) => sender.class.add("visited"));
a.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Back")));
});
const confirm = motifComponent("form", (form: ComponentBase) => {
form.class.add("confirm");
form.motif.on("submit:prevent", () => remove(note));
});
<button type="button" onclick={() => this.notes.togglePin(note)}>Pin</button>
<input class="editor__title" oninput={(e) => (note.title = e.target.value)} />
<a rel="router" href={`/${this.folder}`} onclick={(sender, e) => sender.class.add("visited")}>Back</a>
<form class="confirm" onsubmit:prevent={() => this.remove(note)}>…</form>
<button type="button" onclick={() => notes.togglePin(note)}>Pin</button>
<input class="editor__title" oninput={(e) => (note.title = e.target.value)} />
<a rel="router" href={`/${folder()}`} onclick={(sender, e) => sender.class.add("visited")}>Back</a>
<form class="confirm" onsubmit:prevent={() => remove(note)}>…</form>
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.notes.togglePin(note));
button.controls.add(motifComponent("text", (node) => node.setText("Pin")));
});
const title = motifComponent("input", (input) => {
input.class.add("editor__title");
input.motif.on("input", (_sender, e) => (note.title = e.target.value));
});
const back = motifComponent("a", (a) => {
a.attr.add({ rel: "router", href: () => `/${this.folder}` });
a.motif.on("click", (sender, _e) => sender.class.add("visited"));
a.controls.add(motifComponent("text", (node) => node.setText("Back")));
});
const confirm = motifComponent("form", (form) => {
form.class.add("confirm");
form.motif.on("submit:prevent", () => this.remove(note));
});
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => notes.togglePin(note));
button.controls.add(motifComponent("text", (node) => node.setText("Pin")));
});
const title = motifComponent("input", (input) => {
input.class.add("editor__title");
input.motif.on("input", (_sender, e) => (note.title = e.target.value));
});
const back = motifComponent("a", (a) => {
a.attr.add({ rel: "router", href: () => `/${folder()}` });
a.motif.on("click", (sender, _e) => sender.class.add("visited"));
a.controls.add(motifComponent("text", (node) => node.setText("Back")));
});
const confirm = motifComponent("form", (form) => {
form.class.add("confirm");
form.motif.on("submit:prevent", () => remove(note));
});
An error thrown by a handler (including a rejected promise in an async handler) is reported to errorHandler with the code MJX123.
A ternary written in a handler position (onclick={a ? b : c}) does not work; see Ternaries in attributes. Event names are written in lower case (onclick, onchange, oninput). Modifiers (:once, :prevent, :stop and so on) are supported; a JSX attribute name can take only one :, so on a tag you write one modifier per attribute, while motif.on("click:once:prevent", fn) chains them — see Events for details.
The on:name / on-name / on_name spelling attaches the handler as it is to the tag's component with motif.on("name", fn) (only the prefix is dropped: on-note-saved → note-saved). It is used for DOM events the compiler does not recognize (mouseenter) and for custom events (motif.trigger), and the same rule about the number of parameters applies:
<NoteRow note={note} folder={this.folder} on:mouseenter={() => (this.state.hovered = note.id)} />
<button type="button" on:click={(sender, e) => sender.motif.hide()}>Close</button>
<NoteRow note={note} folder={folder()} on:mouseenter={() => (state.hovered = note.id)} />
<button type="button" on:click={(sender, e) => sender.motif.hide()}>Close</button>
const row = motifComponent(NoteRow, {
note,
folder: this.folder,
initializeComponent: (row: ComponentBase) => row.motif.on("mouseenter", () => (this.state.hovered = note.id)),
});
const close = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", (sender: ComponentBase, _e: Event) => sender.motif.hide());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Close")));
});
const row = motifComponent(NoteRow, {
note,
folder: folder(),
initializeComponent: (row: ComponentBase) => row.motif.on("mouseenter", () => (state.hovered = note.id)),
});
const close = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", (sender: ComponentBase, _e: Event) => sender.motif.hide());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Close")));
});
<NoteRow note={note} folder={this.folder} on:mouseenter={() => (this.state.hovered = note.id)} />
<button type="button" on:click={(sender, e) => sender.motif.hide()}>Close</button>
<NoteRow note={note} folder={folder()} on:mouseenter={() => (state.hovered = note.id)} />
<button type="button" on:click={(sender, e) => sender.motif.hide()}>Close</button>
const row = motifComponent(NoteRow, {
note,
folder: this.folder,
initializeComponent: (row) => row.motif.on("mouseenter", () => (this.state.hovered = note.id)),
});
const close = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", (sender, _e) => sender.motif.hide());
button.controls.add(motifComponent("text", (node) => node.setText("Close")));
});
const row = motifComponent(NoteRow, {
note,
folder: folder(),
initializeComponent: (row) => row.motif.on("mouseenter", () => (state.hovered = note.id)),
});
const close = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", (sender, _e) => sender.motif.hide());
button.controls.add(motifComponent("text", (node) => node.setText("Close")));
});
A handler given with on: on a component tag is attached to the component's element; in a function component that is the returned root (the <a> in NoteRow).
Special x- attributes
Helper props the compiler recognizes that are not DOM attributes, and the calls they come down to:
| Attribute | What it does | Without JSX |
|---|---|---|
x-ref={(s) => ...} / x-ref={this.field} / x-ref={field} |
Captures the tag's component instance; the same as the short ref, and both forms work on every tag. If ref and x-ref are both written on the same tag, both run in source order — see Element and Appearance for details. |
ref: (s) => … in the object form |
x-key={...} |
The same as key (indexkey is accepted too); see Item identity with key. |
indexkey: () => … |
x-text={() => text} |
Sets the element's whole textContent reactively; if the element has other children, it removes them from the DOM on the first change. For live text that sits next to its siblings, write {() => text} in child position. |
bindings.text(fn) |
x-html={() => html} |
Sets innerHTML reactively. |
bindings.html(fn) |
x-value={() => value} |
Sets the element's value property one-way, reactively; for both directions use x-model. |
bindings.value(fn) |
x-model={() => state.query} |
A two-way binding; see Forms for details. | bindings.model(() => state.query, (v) => (state.query = v)) |
x-watch={() => ...} |
Sets up a watcher tied to the tag's component: reactive reads inside the function are tracked and it runs again on change; it stops when the component is disposed. It writes nothing to the DOM. | bindings.watch(fn) |
x-wait={() => bool} |
Holds/hides the component while the condition is true. The primary way to render conditionally. |
preconfig: (s) => s.bindings.wait(fn) |
x-display={() => bool} |
The reverse of x-wait: shows while the condition is true and hides while it is false. |
preconfig: (s) => s.bindings.display(fn) |
x-chunk / x-chunk={50} |
Builds the element's children in slices: the first 100 children added together (the first 50 if a number is given) are built right away, and the rest leave a marker and are built over the following frames. The framework decides how many to build in a frame by looking at the frame time; if a number is given, at least that many are built in each frame. {list.map(...)} rows under it are created when their turn comes. See Large lists. |
options: { chunk: true } / options: { chunk: 50 } |
x-style={...} |
The same as style (a getter is live). |
style(...) |
x-building, x-built, x-config, x-configured, x-mounted, x-activated, x-deactivated, x-disposing, x-disposed, x-initializing, x-initialized, x-visibilitychanged |
Lifecycle hooks (in prop form; the x: spelling works too). Several spellings on the same tag, such as onBuilt and x-built, are combined, and all of them run in source order. |
onBuilt: (s, e) => … in the object form, or s.motif.on("x:built", …) during setup |
x-text, x-html, x-value, x-model and x-watch want a getter, a method reference or an object; if a plain string or boolean is given, compilation stops with MJX011. In every directive, x-wait and x-display included, a variable or member that holds a function is called as a getter: x-display={isAuthor} and x-wait={this.isLoading} (called with this) work the same as x-display={() => isAuthor()}. x-chunk is the exception: it is written without a value or takes a number (x-chunk, x-chunk={50}, x-chunk={this.minRows}); if a string, an object or an arrow function is given, compilation stops with MJX012. If a variable or member holding a function is given, the function is called once while the component is being built and the returned value is used; if the value changes later, that is not tracked. options: { chunk } follows the same rule.
Outside the core's directives, a name that starts with x is an ordinary name: xField, x-field or x:field stays as written. On a component tag it becomes a prop of the same name (this.props["x-field"]), on a plain tag it is written as an attribute. The compiler gives no warning for these names, so a misspelled directive (x-wiat) also stays an ordinary prop.
x-wait and x-display are evaluated before the component is built, which is why without JSX they are written in preconfig, not in initializeComponent. The notes list holds the row box until the first note arrives, and the empty list paragraph is shown only when there are no visible rows:
<div class="rows" x-wait={() => this.notes.state.items.length === 0}>…</div>
<p class="list__empty" x-display={() => this.visible.length === 0}>…</p>
<div class="rows" x-wait={() => notes.state.items.length === 0}>…</div>
<p class="list__empty" x-display={() => visible().length === 0}>…</p>
motifComponent("div", {
preconfig: (rows: ComponentBase) => rows.bindings.wait(() => this.notes.state.items.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"),
});
motifComponent("div", {
preconfig: (rows: ComponentBase) => rows.bindings.wait(() => notes.state.items.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"),
});
<div class="rows" x-wait={() => this.notes.state.items.length === 0}>…</div>
<p class="list__empty" x-display={() => this.visible.length === 0}>…</p>
<div class="rows" x-wait={() => notes.state.items.length === 0}>…</div>
<p class="list__empty" x-display={() => visible().length === 0}>…</p>
motifComponent("div", {
preconfig: (rows) => rows.bindings.wait(() => this.notes.state.items.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"),
});
motifComponent("div", {
preconfig: (rows) => rows.bindings.wait(() => notes.state.items.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"),
});
If the condition is true on the first evaluation (false for x-display), the component is never built, and its subtree does not enter the DOM either. When it opens later it is built once, and when it closes again the instance is kept. {condition && <X/>}, on the other hand, builds a new instance every time it opens and disposes it every time it closes; for a detailed comparison see Conditional Rendering and Lists.
Ternaries in attributes
A ternary operator written in an attribute or a component prop is always wrapped lazily: when <Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} /> is compiled, the prop becomes hint: () => settings.state.theme === "system" ? … : ….
This is a deliberate design decision: without the wrapping, the expression would be evaluated once during view() and frozen. The other half of it is that the component receiving the prop declares it as Bind<T> and reads it with read(); the hint prop of Setting works like that (see Plain values and live values). A component that uses a plainly typed prop directly breaks this contract and receives a function at run time.
Other expressions in a component prop ({p.a}, {f()}, {a && b}) are not wrapped and are passed as the value taken while the tag is being built; on a plain DOM tag, {p.a} and {a && b} are wrapped in a getter and stay live, while {f()} is evaluated once (see Attributes). Without JSX there is no wrapping; you pass a prop you want to be live as a getter yourself: motifComponent(Setting, { name: "Theme", hint: () => (… ? "Follows the system." : "Chosen by hand.") }).
A ternary in an event attribute is wrapped too, but nothing reads this getter: onclick={note.pinned ? undefined : () => notes.archive(note)} registers the arrow function as the listener; on click the chosen handler is returned but not called, and there is no compile warning or motif-lint finding either. The same holds for on:click, onclick:once and an event on a component tag. Write the condition inside the handler: onclick={() => { if (!note.pinned) notes.archive(note); }}.
Compile-time warnings
@motifx/compiler reports a few forms that quietly do the wrong thing to the terminal during compilation. Warnings do not stop the build; they appear next to the code, with the file, the line and a code frame.
For MJX004, MJX017 and MJX018 the check treats these roots as reactive: this.state and this.props; fields set up in a class, in a field initializer or in the constructor, with reactive, useModel, createSignal, createComputed or createLazyComputed; module stores set up with these factories in the same file; and aliases taken from these inside a getter (const rows = this.state.rows, const { rows } = this.state). It does not recognize a store imported from another file, a store created inside a function or returned from one, an injected service, or the props of a function component; the compiler does not see the same pattern there. Inside a getter, callbacks passed to synchronous array methods such as map, forEach, filter, reduce, flatMap, some, every and find count as part of the getter; event handlers, setTimeout and then callbacks do not.
| Code | What it catches | What to write instead |
|---|---|---|
MJX001 |
Declaring a local variable and returning a condition inside a block-bodied arrow function with parameters in JSX child position. The compiler moves the condition out of the closure, the variable is not visible in the branches, and you get a ReferenceError. (In an arrow without parameters, local variables are kept.) |
Use an expression-bodied arrow, or move the logic into a named method. |
MJX002 |
A camelCase spelling of a DOM event name the compiler recognizes on a component tag (onChange, onClick). It looks like a callback prop but becomes a DOM listener on the component's element, and props.onChange stays undefined. Names the compiler does not recognize (onmouseenter, onpointer…, ontouch…, onfocusin, onanimation…, ontransition…) set up no listener on a component tag: the value is passed as an ordinary prop, and no warning appears. |
Pick a name that does not clash (onValueChange, onConfirm). For a deliberate DOM listener on a recognized event, write it in lower case: <Button onclick={…}> — this produces no warning. To listen to an unrecognized event on the component's element, use the on: spelling: <NoteRow on:mouseenter={…}> (see The on:name spelling). In a component without an element (a fragment), the listener listens on the top-level elements of the content. |
MJX004 |
Inside a reactive getter (a JSX getter, an attribute getter, a directive, effect, createComputed, bindings.*), a reactive list is scanned with find/findIndex/findLast/some/every and a predicate of the form x.field === value: every run reads that field of every element up to the match and subscribes to it, so if n components carry this getter, every change means n² reads. The list must be rooted at this. or be a store set up in the same file with reactive/useModel/a signal; includes, filter, map and predicates without a field comparison do not warn. |
Take the element from a keyed map or from a computed calculated once; if the list is only needed at that moment, read it with untracked(() => list.find(...)). See The trap of every row scanning the list. |
MJX015 |
In a class component, a member that overrides a ComponentBase method (build, dispose, style, setText …) or accessor (context, isWait …) ends on some path without reaching super.<name>: it never calls it, or calls it inside an early return, an if with one branch, a loop, a catch or a callback. On that path, the work MotifJS does in that method does not run (a component left empty, a page not torn down on navigation, a lost style). The check also runs in .ts / .js files without JSX, and the base class is followed across files. |
Rename the member, or call super.<name>(…) on every path. An early return on this.isBuilt / this.isDisposed / this.isWait in build, and on this.isDisposed in dispose, is exempt; the base method does nothing in that case either. See Names of class members. |
MJX016 |
A switch or return a ? b : c getter in JSX child position could not be turned into switchCase/ternary: a case label that is not a literal, a return or a statement producing JSX before the switch, a break that leaves the switch. The getter runs as written, as bindings.method; branches are not kept, and the content is rebuilt on every change. |
Make the label a literal or move the condition into the branches; if keeping branches is not needed, the warning is for information. |
MJX017 |
A reactive getter writes a reactive value it reads: ++/--, a compound assignment such as +=, or a plain assignment to a field the getter reads elsewhere ({() => this.state.n++}, effect(() => { stats.runs++; })). A getter that writes what it reads triggers itself and is stopped at run time with MJX203. Reactive roots: this.state, this.props, fields set up in a class with reactive/useModel/a signal, and module stores in the same file. A plain assignment to a field the getter does not read (this.state.query = seed), writing to a plain class field (this.loadedId = id), writes in event handlers (onclick={() => …}) and writes inside untracked(...) to a value the getter does not read with tracking elsewhere do not warn. |
Move the write into an event handler or a method; if it has to stay, put the read of that value inside untracked so the getter does not subscribe to it (the whole expression for x++ and compound assignment, or the read in the condition for a lock flag). |
MJX018 |
A reactive list is changed in place inside a reactive getter: sort/reverse always (they read and write the list: {() => this.state.items.sort(cmp)}), and push/pop/shift/unshift/splice when the getter also reads that list (such as stats.log.length). A getter that reads the list triggers itself. Working on a copy ([...list].sort(), list.slice().sort()), an effect(() => { log.push(x); }) that only writes, and event handlers do not warn. |
Sort a copy, or keep the sorted list in a field or a computed. |
To turn the warnings off, pass diagnostics: false to the Vite plugin:
compiler({ diagnostics: false })
compiler({ diagnostics: false })
Compile errors
The forms below cannot be brought down to working code; the compiler stops the build and gives the error with the file, the line and a code frame. The code field of the error object is the code.
| Code | What it catches | What to write instead |
|---|---|---|
MJX008 |
A function () { … } expression in child position. |
Write an arrow function: {() => …}. |
MJX009 |
A string or boolean value on a DOM event (onclick="go()"). |
Give a handler function: onclick={() => go()}. |
MJX010 |
A value that is not a function on a lifecycle hook (onBuilt="x"). |
Give a function, a list of functions or a reference to a function. |
MJX011 |
A value that cannot be turned into a getter on the x-text, x-html, x-value, x-model or x-watch directive (x-text="plain"). |
Give a getter or a reference: x-text={() => note.title}. |
MJX012 |
An object literal on the x-wait/x-display directive; a value that cannot be a number (a string, an object, an arrow function) on the x-chunk directive. |
Give an expression or a getter that returns a boolean; write x-chunk without a value or give it a number: x-chunk={50}. |
MJX013 |
A JSX form the compiler does not support: a namespaced tag name (<svg:rect>) or a spread in child position ({...items}). A lower-case name without a hyphen that is not an HTML tag (<card>) does not give this error; it resolves to the identifier of the same name in scope, and if there is no such identifier, the error arrives not at compile time but at run time, as a ReferenceError. |
Remove the namespace prefix (<rect>). Place a ready array of components with {items}, and build a list from data with {items.map(…)}. |
Type-aware checking: motif-lint (MJX005, MJX015)
The compiler cannot see prop types. A ternary in an attribute is always wrapped with () => … (see Ternaries in attributes), so with <NoteRow folder={note.archived ? "archive" : "notes"}/>, if folder is declared as a plain Folder, the component receives a function at run time; TypeScript sees no problem either, because the type of the ternary is that union. A separate check that runs over the TypeScript program closes this gap:
npx motif-lint # ./tsconfig.json
npx motif-lint -p tsconfig.app.json src/pages/NotePage.tsx # one project / one file
npx motif-lint --json --no-fail # machine output for CI, exit code 0
| Code | What it catches | What to write instead |
|---|---|---|
MJX005 |
A ternary is written on a component prop, but the prop's declared type does not accept a function (folder: Folder). |
Declare the prop as Bind<T> in the component and read it with read()/toGetter(). If the value really is static, compute the ternary outside JSX and pass the variable. |
MJX015 |
The same rule as the compile warning (an override that does not reach super). motif-lint resolves the base class with the TypeScript type checker and scans every file in the tsconfig, so files the Vite plugin does not see (tests, for example) are checked as well. |
See MJX015 above. |
The rule stays silent on DOM tags, on props typed as Bind<T>/any/unknown/a function, on the key/x-*/on:* names and on generic (value: T) props. If there are findings, the exit code is 1; you can put "lint:jsx": "motif-lint" in package.json and hook it into CI. If tsconfig cannot be read, the command stops with an MJX014 error.
What did this expression compile to? — explain
In MotifJS, JSX is not a view language but a short way of writing the binding API (bindings.add, bindings.when, controls.add…), and the compiler is not a transformer but a lowering stage in the classic sense. Looking for a resemblance between the source and the output is pointless, but the question "what will happen when this runs" is not left to guesswork. explain, which works like a compiler's -S flag, prints for each JSX expression the real call it comes down to (taken from the generated code, not guessed), its reactivity class and its dependency surface:
npx motif-explain src/pages/SettingsPage.tsx # readable listing
npx motif-explain src/pages/SettingsPage.tsx --site prop # component props only
npx motif-explain src/pages/SettingsPage.tsx --json --code # machine output + generated code
For this file:
import { Component } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SettingsService } from "../services/settings";
import { Setting } from "../parts/Setting";
const archived = (count: number) => `${count} archived notes`;
export default class SettingsPage extends Component {
notes = this.getService(NotesService)!;
settings = this.getService(SettingsService)!;
override view() {
return (
<section class="page" title={archived(this.notes.count("archive"))}>
<p>{this.settings.state.theme}</p>
<small>{() => archived(this.notes.count("archive"))}</small>
<Setting name="Theme" hint={this.settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} />
</section>
);
}
}
import { Component } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SettingsService } from "../services/settings";
import { Setting } from "../parts/Setting";
const archived = (count) => `${count} archived notes`;
export default class SettingsPage extends Component {
notes = this.getService(NotesService);
settings = this.getService(SettingsService);
view() {
return (
<section class="page" title={archived(this.notes.count("archive"))}>
<p>{this.settings.state.theme}</p>
<small>{() => archived(this.notes.count("archive"))}</small>
<Setting name="Theme" hint={this.settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} />
</section>
);
}
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SettingsService } from "../services/settings";
import { Setting } from "../parts/Setting";
const archived = (count: number) => `${count} archived notes`;
export default function SettingsPage() {
const notes = FromService(NotesService)!;
const settings = FromService(SettingsService)!;
return (
<section class="page" title={archived(notes.count("archive"))}>
<p>{settings.state.theme}</p>
<small>{() => archived(notes.count("archive"))}</small>
<Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} />
</section>
);
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SettingsService } from "../services/settings";
import { Setting } from "../parts/Setting";
const archived = (count) => `${count} archived notes`;
export default function SettingsPage() {
const notes = FromService(NotesService);
const settings = FromService(SettingsService);
return (
<section class="page" title={archived(notes.count("archive"))}>
<p>{settings.state.theme}</p>
<small>{() => archived(notes.count("archive"))}</small>
<Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} />
</section>
);
}
import { motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SettingsService } from "../services/settings";
import { Setting } from "../parts/Setting";
const archived = (count: number) => `${count} archived notes`;
export default () => motifOptions({
el: null,
view() {
const notes = this.getService(NotesService)!;
const settings = this.getService(SettingsService)!;
return (
<section class="page" title={archived(notes.count("archive"))}>
<p>{settings.state.theme}</p>
<small>{() => archived(notes.count("archive"))}</small>
<Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} />
</section>
);
},
});
import { motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SettingsService } from "../services/settings";
import { Setting } from "../parts/Setting";
const archived = (count) => `${count} archived notes`;
export default () => motifOptions({
el: null,
view() {
const notes = this.getService(NotesService);
const settings = this.getService(SettingsService);
return (
<section class="page" title={archived(notes.count("archive"))}>
<p>{settings.state.theme}</p>
<small>{() => archived(notes.count("archive"))}</small>
<Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."} />
</section>
);
},
});
The listing:
[motifjs explain] src/pages/SettingsPage.tsx — 9 expressions
14:16 attr/attr.literal class="page"
=> sender.class.add("page")
STATIC · deps: none — no binding is created
14:29 attr/attr.call title={archived(this.notes.count("archive"))}
=> sender.attr.add({ "title": archived(this.notes.count("archive")) })
ONCE · deps: none — evaluated once during setup, then frozen
note: a plain function call is passed as a VALUE; write `() => f(x)` to keep it live
15:9 element/element.dom <p>
=> sender.controls.add(_mc(<p>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
15:12 child/text.field {this.settings.state.theme}
=> sender.bindings.add("textContent", this.settings.state, "theme")
LIVE · deps: this.settings.state.theme — this field only (exact)
16:9 element/element.dom <small>
=> sender.controls.add(_mc(<small>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
16:16 child/method {() => archived(this.notes.count("archive"))}
=> sender.bindings.method(() => archived(this.notes.count("archive")))
LIVE · deps: ALL reactive fields read inside the getter; collected again on every run
note: reactive text if the result is text, a Frame if it is a component (the instance is rebuilt on every change)
17:9 element/element.component <Setting name="Theme" hint={this.settings.state.theme === "system" ? "Follows the system.…
=> sender.controls.add(_mc(<Setting>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
17:18 prop/prop.literal name="Theme"
=> name: "Theme"
STATIC · deps: none — no binding is created
17:31 prop/prop.ternary hint={this.settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."}
=> hint: () => this.settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."
RECEIVER · deps: wherever the receiving component reads the getter; nowhere if it does not
note: ternary wrapped lazily (by design): the receiving component should declare the prop as Bind<T> and read it with read()/toGetter()
[motifjs explain] src/pages/SettingsPage.jsx — 9 expressions
14:16 attr/attr.literal class="page"
=> sender.class.add("page")
STATIC · deps: none — no binding is created
14:29 attr/attr.call title={archived(this.notes.count("archive"))}
=> sender.attr.add({ "title": archived(this.notes.count("archive")) })
ONCE · deps: none — evaluated once during setup, then frozen
note: a plain function call is passed as a VALUE; write `() => f(x)` to keep it live
15:9 element/element.dom <p>
=> sender.controls.add(_mc(<p>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
15:12 child/text.field {this.settings.state.theme}
=> sender.bindings.add("textContent", this.settings.state, "theme")
LIVE · deps: this.settings.state.theme — this field only (exact)
16:9 element/element.dom <small>
=> sender.controls.add(_mc(<small>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
16:16 child/method {() => archived(this.notes.count("archive"))}
=> sender.bindings.method(() => archived(this.notes.count("archive")))
LIVE · deps: ALL reactive fields read inside the getter; collected again on every run
note: reactive text if the result is text, a Frame if it is a component (the instance is rebuilt on every change)
17:9 element/element.component <Setting name="Theme" hint={this.settings.state.theme === "system" ? "Follows the system.…
=> sender.controls.add(_mc(<Setting>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
17:18 prop/prop.literal name="Theme"
=> name: "Theme"
STATIC · deps: none — no binding is created
17:31 prop/prop.ternary hint={this.settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."}
=> hint: () => this.settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."
RECEIVER · deps: wherever the receiving component reads the getter; nowhere if it does not
note: ternary wrapped lazily (by design): the receiving component should declare the prop as Bind<T> and read it with read()/toGetter()
[motifjs explain] src/pages/SettingsPage.tsx — 9 expressions
13:14 attr/attr.literal class="page"
=> sender.class.add("page")
STATIC · deps: none — no binding is created
13:27 attr/attr.call title={archived(notes.count("archive"))}
=> sender.attr.add({ "title": archived(notes.count("archive")) })
ONCE · deps: none — evaluated once during setup, then frozen
note: a plain function call is passed as a VALUE; write `() => f(x)` to keep it live
14:7 element/element.dom <p>
=> sender.controls.add(_mc(<p>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
14:10 child/text.field {settings.state.theme}
=> sender.bindings.add("textContent", settings.state, "theme")
LIVE · deps: settings.state.theme — this field only (exact)
15:7 element/element.dom <small>
=> sender.controls.add(_mc(<small>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
15:14 child/method {() => archived(notes.count("archive"))}
=> sender.bindings.method(() => archived(notes.count("archive")))
LIVE · deps: ALL reactive fields read inside the getter; collected again on every run
note: reactive text if the result is text, a Frame if it is a component (the instance is rebuilt on every change)
16:7 element/element.component <Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "…
=> sender.controls.add(_mc(<Setting>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
16:16 prop/prop.literal name="Theme"
=> name: "Theme"
STATIC · deps: none — no binding is created
16:29 prop/prop.ternary hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."}
=> hint: () => settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."
RECEIVER · deps: wherever the receiving component reads the getter; nowhere if it does not
note: ternary wrapped lazily (by design): the receiving component should declare the prop as Bind<T> and read it with read()/toGetter()
[motifjs explain] src/pages/SettingsPage.jsx — 9 expressions
13:14 attr/attr.literal class="page"
=> sender.class.add("page")
STATIC · deps: none — no binding is created
13:27 attr/attr.call title={archived(notes.count("archive"))}
=> sender.attr.add({ "title": archived(notes.count("archive")) })
ONCE · deps: none — evaluated once during setup, then frozen
note: a plain function call is passed as a VALUE; write `() => f(x)` to keep it live
14:7 element/element.dom <p>
=> sender.controls.add(_mc(<p>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
14:10 child/text.field {settings.state.theme}
=> sender.bindings.add("textContent", settings.state, "theme")
LIVE · deps: settings.state.theme — this field only (exact)
15:7 element/element.dom <small>
=> sender.controls.add(_mc(<small>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
15:14 child/method {() => archived(notes.count("archive"))}
=> sender.bindings.method(() => archived(notes.count("archive")))
LIVE · deps: ALL reactive fields read inside the getter; collected again on every run
note: reactive text if the result is text, a Frame if it is a component (the instance is rebuilt on every change)
16:7 element/element.component <Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "…
=> sender.controls.add(_mc(<Setting>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
16:16 prop/prop.literal name="Theme"
=> name: "Theme"
STATIC · deps: none — no binding is created
16:29 prop/prop.ternary hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."}
=> hint: () => settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."
RECEIVER · deps: wherever the receiving component reads the getter; nowhere if it does not
note: ternary wrapped lazily (by design): the receiving component should declare the prop as Bind<T> and read it with read()/toGetter()
[motifjs explain] src/pages/SettingsPage.tsx — 9 expressions
15:16 attr/attr.literal class="page"
=> sender.class.add("page")
STATIC · deps: none — no binding is created
15:29 attr/attr.call title={archived(notes.count("archive"))}
=> sender.attr.add({ "title": archived(notes.count("archive")) })
ONCE · deps: none — evaluated once during setup, then frozen
note: a plain function call is passed as a VALUE; write `() => f(x)` to keep it live
16:9 element/element.dom <p>
=> sender.controls.add(_mc(<p>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
16:12 child/text.field {settings.state.theme}
=> sender.bindings.add("textContent", settings.state, "theme")
LIVE · deps: settings.state.theme — this field only (exact)
17:9 element/element.dom <small>
=> sender.controls.add(_mc(<small>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
17:16 child/method {() => archived(notes.count("archive"))}
=> sender.bindings.method(() => archived(notes.count("archive")))
LIVE · deps: ALL reactive fields read inside the getter; collected again on every run
note: reactive text if the result is text, a Frame if it is a component (the instance is rebuilt on every change)
18:9 element/element.component <Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "…
=> sender.controls.add(_mc(<Setting>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
18:18 prop/prop.literal name="Theme"
=> name: "Theme"
STATIC · deps: none — no binding is created
18:31 prop/prop.ternary hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."}
=> hint: () => settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."
RECEIVER · deps: wherever the receiving component reads the getter; nowhere if it does not
note: ternary wrapped lazily (by design): the receiving component should declare the prop as Bind<T> and read it with read()/toGetter()
[motifjs explain] src/pages/SettingsPage.jsx — 9 expressions
15:16 attr/attr.literal class="page"
=> sender.class.add("page")
STATIC · deps: none — no binding is created
15:29 attr/attr.call title={archived(notes.count("archive"))}
=> sender.attr.add({ "title": archived(notes.count("archive")) })
ONCE · deps: none — evaluated once during setup, then frozen
note: a plain function call is passed as a VALUE; write `() => f(x)` to keep it live
16:9 element/element.dom <p>
=> sender.controls.add(_mc(<p>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
16:12 child/text.field {settings.state.theme}
=> sender.bindings.add("textContent", settings.state, "theme")
LIVE · deps: settings.state.theme — this field only (exact)
17:9 element/element.dom <small>
=> sender.controls.add(_mc(<small>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
17:16 child/method {() => archived(notes.count("archive"))}
=> sender.bindings.method(() => archived(notes.count("archive")))
LIVE · deps: ALL reactive fields read inside the getter; collected again on every run
note: reactive text if the result is text, a Frame if it is a component (the instance is rebuilt on every change)
18:9 element/element.component <Setting name="Theme" hint={settings.state.theme === "system" ? "Follows the system." : "…
=> sender.controls.add(_mc(<Setting>, …))
STATIC · deps: none — the element itself is built once; bindings inside it are their own effects
18:18 prop/prop.literal name="Theme"
=> name: "Theme"
STATIC · deps: none — no binding is created
18:31 prop/prop.ternary hint={settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."}
=> hint: () => settings.state.theme === "system" ? "Follows the system." : "Chosen by hand."
RECEIVER · deps: wherever the receiving component reads the getter; nowhere if it does not
note: ternary wrapped lazily (by design): the receiving component should declare the prop as Bind<T> and read it with read()/toGetter()
Reactivity classes:
| Class | Meaning |
|---|---|
LIVE |
An effect is set up; the DOM is updated when the dependencies change. |
STATIC |
No binding is set up: literal values (string, number, negative number, boolean, null), a valueless attribute, a prop whose value is JSX, fixed forms such as transition/options, and the element itself (<p>, <Setting …/>); the element is built once, and the expressions inside it appear on their own lines. |
ONCE |
The expression is evaluated once at setup and never read again (attr={f(x)}, prop={object}). With prop={object} the receiver gets the object itself: if it reads the fields of a reactive object inside a binding, it sees changes made in place, but it does not see the object being replaced by another one at the source. |
RECEIVER |
The expression is passed as a function; whether it is live depends on the receiving component's Bind<T> contract. |
RUNTIME |
The value's type is known only at run time: live if it is a function, once otherwise (attr={variable}). |
To see the same listing on the development server, give explain to the Vite plugin; a string or a RegExp lists only the matching files, and the output code does not change:
compiler({ explain: "pages/SettingsPage" })
compiler({ explain: "pages/SettingsPage" })
Programmatic use: import { explain } from '@motifx/compiler' → explain(source, fileName) returns a MotifExplanation[] (site, shape, source, lowered, reactive, deps, note).
Item identity with key
Give list items a key; rows are matched by the item object, not by key. The counterpart without JSX is indexkey. In development mode a duplicate key is reported with MJX202. For details see Conditional Rendering and Lists:
{this.visible.map((note) => <NoteRow key={note.id} note={note} folder={this.folder} />)}
{visible().map((note) => <NoteRow key={note.id} note={note} folder={folder()} />)}
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() })),
});
{this.visible.map((note) => <NoteRow key={note.id} note={note} folder={this.folder} />)}
{visible().map((note) => <NoteRow key={note.id} note={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() })),
});
Transitions — the transition prop
To give a component an enter/leave animation, use the transition prop. If you pass a string, it becomes a CSS class transition whose class names are derived from that prefix; if you pass an object, you can specify custom class names. Without JSX it is a key of the object form:
<a class="row" transition="fade">…</a>
motifComponent("a", { transition: "fade", initializeComponent: (a: ComponentBase) => a.class.add("row") });
<a class="row" transition="fade">…</a>
motifComponent("a", { transition: "fade", initializeComponent: (a) => a.class.add("row") });
This applies the CSS classes fade-enter-from/active/to and fade-leave-from/active/to. For details and WAAPI (keyframe) based animation, see Styling and Animation.
transition does not write an attribute on a plain DOM tag; it goes to the transition engine. The options prop is the same: hideStrategy and disableDisposal in <li options={{ hideStrategy: 'detach', disableDisposal: true }}/> are copied into that tag's motif.options, and no attribute is written to the DOM; without JSX, options is also a key of the object form.
Next step
To write the same view without JSX, with component calls, go on to Views without JSX; the details of reactive state are in Reactivity.