Core concepts
Components
Class, function and Options API components, with and without JSX; the root element, `view()` and `controls`, names of class members.
In MotifJS a component is an object that manages a DOM element and its child controls (controls). Every component derives from ComponentBase; in everyday use you work with the concrete Component class. The view is written in JSX or built directly with the same calls, and every example on this page is given with and without JSX, in TypeScript and JavaScript.
There are three styles for writing a component: class, function and Options API. All three use the same core, and which one you pick is a matter of preference. The examples come from a notes application, and this service is shared by all of them:
import { reactive } from "@motifx/core";
export interface Note {
id: number;
title: string;
body: string;
pinned: boolean;
archived: boolean;
createdAt: number;
updatedAt: number;
}
export type Folder = "notes" | "pinned" | "archive";
export const FOLDERS: Record<Folder, { title: string; empty: string }> = {
notes: { title: "All notes", empty: "No notes yet. Write the first one." },
pinned: { title: "Pinned", empty: "No pinned notes. Pin a note you want to keep at the top." },
archive: { title: "Archive", empty: "The archive is empty." },
};
const inFolder: Record<Folder, (note: Note) => boolean> = {
notes: (note) => !note.archived,
pinned: (note) => note.pinned && !note.archived,
archive: (note) => note.archived,
};
export class NotesService {
readonly state = reactive({ items: [] as Note[] });
private nextId = this.state.items.length + 1;
list(folder: Folder, query = ""): Note[] {
const q = query.trim().toLowerCase();
return this.state.items
.filter(inFolder[folder])
.filter((note) => !q || `${note.title}\n${note.body}`.toLowerCase().includes(q))
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt);
}
count(folder: Folder): number {
return this.state.items.filter(inFolder[folder]).length;
}
find(id: number | string): Note | undefined {
const wanted = Number(id);
return this.state.items.find((note) => note.id === wanted);
}
create(): Note {
const now = Date.now();
this.state.items.unshift({ id: this.nextId++, title: "", body: "", pinned: false, archived: false, createdAt: now, updatedAt: now });
return this.state.items[0];
}
touch(note: Note): void {
note.updatedAt = Date.now();
}
togglePin(note: Note): void {
note.pinned = !note.pinned;
this.touch(note);
}
archive(note: Note): void {
note.archived = true;
note.pinned = false;
this.touch(note);
}
restore(note: Note): void {
note.archived = false;
this.touch(note);
}
remove(id: number): boolean {
const index = this.state.items.findIndex((note) => note.id === id);
if (index < 0) return false;
this.state.items.splice(index, 1);
return true;
}
emptyArchive(): number {
let removed = 0;
for (let i = this.state.items.length - 1; i >= 0; i--) {
if (!this.state.items[i].archived) continue;
this.state.items.splice(i, 1);
removed++;
}
return removed;
}
}
import { reactive } from "@motifx/core";
export const FOLDERS = {
notes: { title: "All notes", empty: "No notes yet. Write the first one." },
pinned: { title: "Pinned", empty: "No pinned notes. Pin a note you want to keep at the top." },
archive: { title: "Archive", empty: "The archive is empty." },
};
const inFolder = {
notes: (note) => !note.archived,
pinned: (note) => note.pinned && !note.archived,
archive: (note) => note.archived,
};
export class NotesService {
state = reactive({ items: [] });
nextId = this.state.items.length + 1;
list(folder, query = "") {
const q = query.trim().toLowerCase();
return this.state.items
.filter(inFolder[folder])
.filter((note) => !q || `${note.title}\n${note.body}`.toLowerCase().includes(q))
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt);
}
count(folder) {
return this.state.items.filter(inFolder[folder]).length;
}
find(id) {
const wanted = Number(id);
return this.state.items.find((note) => note.id === wanted);
}
create() {
const now = Date.now();
this.state.items.unshift({ id: this.nextId++, title: "", body: "", pinned: false, archived: false, createdAt: now, updatedAt: now });
return this.state.items[0];
}
touch(note) {
note.updatedAt = Date.now();
}
togglePin(note) {
note.pinned = !note.pinned;
this.touch(note);
}
archive(note) {
note.archived = true;
note.pinned = false;
this.touch(note);
}
restore(note) {
note.archived = false;
this.touch(note);
}
remove(id) {
const index = this.state.items.findIndex((note) => note.id === id);
if (index < 0) return false;
this.state.items.splice(index, 1);
return true;
}
emptyArchive() {
let removed = 0;
for (let i = this.state.items.length - 1; i >= 0; i--) {
if (!this.state.items[i].archived) continue;
this.state.items.splice(i, 1);
removed++;
}
return removed;
}
}
1. Class components
You derive from Component and return the view from the view() method. NoteEmptyPage is the page shown when no note is open in a folder. It uses the usual parts of a class component: a field that gets a service (this.getService), access to the router (useNavigation()), a getter for a derived value, a method, the onConfig hook and view(). this.context.navigate navigates with the application's router. A class component that takes props and declares their type is on the Props and Children page.
import { Component, useNavigation } from "@motifx/core";
import { FOLDERS, NotesService, type Folder } from "../services/notes";
export default class NoteEmptyPage extends Component {
notes = this.getService(NotesService)!;
nav = useNavigation();
get folder(): Folder {
return this.nav.meta.folder as Folder;
}
create() {
const note = this.notes.create();
this.context.navigate(`/notes/${note.id}`);
}
override onConfig() {
const wide = globalThis.matchMedia?.("(min-width: 40rem)").matches ?? false;
const first = this.notes.list(this.folder)[0];
if (wide && first) this.context.navigate(`/${this.folder}/${first.id}`, { replace: true });
}
override view() {
return (
<div class="pane">
<div class="toolbar">
<span class="toolbar__spacer"></span>
</div>
<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>
</div>
);
}
}
import { Component, ComponentBase, motifComponent, useNavigation } from "@motifx/core";
import { FOLDERS, NotesService, type Folder } from "../services/notes";
export default class NoteEmptyPage extends Component {
notes = this.getService(NotesService)!;
nav = useNavigation();
get folder(): Folder {
return this.nav.meta.folder as Folder;
}
create() {
const note = this.notes.create();
this.context.navigate(`/notes/${note.id}`);
}
override onConfig() {
const wide = globalThis.matchMedia?.("(min-width: 40rem)").matches ?? false;
const first = this.notes.list(this.folder)[0];
if (wide && first) this.context.navigate(`/${this.folder}/${first.id}`, { replace: true });
}
override view() {
return motifComponent("div", (pane: ComponentBase) => {
pane.class.add("pane");
const toolbar = motifComponent("div", (bar: ComponentBase) => {
bar.class.add("toolbar");
bar.controls.add(motifComponent("span", (span: ComponentBase) => span.class.add("toolbar__spacer")));
});
const placeholder = motifComponent("div", (box: ComponentBase) => {
box.class.add("placeholder");
const message = motifComponent("p", (p: ComponentBase) => {
p.bindings.text(() => (this.notes.count(this.folder) ? "Select a note or write a new one." : FOLDERS[this.folder].empty));
});
const newNote = 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, newNote);
});
pane.controls.add(toolbar, placeholder);
});
}
}
import { Component, useNavigation } from "@motifx/core";
import { FOLDERS, NotesService } from "../services/notes";
export default class NoteEmptyPage extends Component {
notes = this.getService(NotesService);
nav = useNavigation();
get folder() {
return this.nav.meta.folder;
}
create() {
const note = this.notes.create();
this.context.navigate(`/notes/${note.id}`);
}
onConfig() {
const wide = globalThis.matchMedia?.("(min-width: 40rem)").matches ?? false;
const first = this.notes.list(this.folder)[0];
if (wide && first) this.context.navigate(`/${this.folder}/${first.id}`, { replace: true });
}
view() {
return (
<div class="pane">
<div class="toolbar">
<span class="toolbar__spacer"></span>
</div>
<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>
</div>
);
}
}
import { Component, motifComponent, useNavigation } from "@motifx/core";
import { FOLDERS, NotesService } from "../services/notes";
export default class NoteEmptyPage extends Component {
notes = this.getService(NotesService);
nav = useNavigation();
get folder() {
return this.nav.meta.folder;
}
create() {
const note = this.notes.create();
this.context.navigate(`/notes/${note.id}`);
}
onConfig() {
const wide = globalThis.matchMedia?.("(min-width: 40rem)").matches ?? false;
const first = this.notes.list(this.folder)[0];
if (wide && first) this.context.navigate(`/${this.folder}/${first.id}`, { replace: true });
}
view() {
return motifComponent("div", (pane) => {
pane.class.add("pane");
const toolbar = motifComponent("div", (bar) => {
bar.class.add("toolbar");
bar.controls.add(motifComponent("span", (span) => span.class.add("toolbar__spacer")));
});
const placeholder = motifComponent("div", (box) => {
box.class.add("placeholder");
const message = motifComponent("p", (p) => {
p.bindings.text(() => (this.notes.count(this.folder) ? "Select a note or write a new one." : FOLDERS[this.folder].empty));
});
const newNote = 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, newNote);
});
pane.controls.add(toolbar, placeholder);
});
}
}
A class component is built with a tag in JSX (<NoteEmptyPage />), and without JSX with new NoteEmptyPage() or motifComponent(NoteEmptyPage).
Root element
The value you pass to super(...) decides the component's own element (this.element):
super('div')→ the component's element is a<div>. For an SVG element writesuper('svg', { options: { isSvg: true } }); the element is created in the SVG namespace.super(node)→ a DOM node you already have (document.createElement('canvas'), an existing element on the page) becomes the component's element; the component does not create it, it takes ownership of it (see Wrapping third-party libraries).- If you pass no argument (
super()), the component has no element of its own (a fragment):this.elementis a comment marker, and the content returned byview()sits between the opening and closing markers (<!--[-->…<!--]-->). In a component without an element,propscan also be passed as the first argument:super(props)is the same assuper(undefined, props), and in a class that does not write a constructor,new MyComponent(props)works the same way. - If you gave an element generic such as
Component<HTMLDivElement>,@motifx/compileraddsstatic elementTag = 'div'to the class; thensuper()creates a real element of that type instead of a fragment (see Root element). For SVG generics such asComponent<SVGSVGElement>the compiler also addsstatic elementNamespace = 'http://www.w3.org/2000/svg', and the element is created in the SVG namespace. You can write the two static fields by hand as well; if they are already there, the compiler leaves them alone. The Vite plugin does this in.ts,.mtsand.ctsfiles too, so in a class without JSX the element type also comes from the generic. In a file the plugin does not process (for example in a library compiled only withtsc), writestatic elementTag = 'ul'or callsuper('ul'). JavaScript has no generics, so there the element is always declared withstatic elementTagorsuper('ul').
If a ready component instance is passed to the constructor (new Component(instance)), no new component is created; that instance is returned.
view() returns the content, not the component's element. In a class that declares no element, like NoteEmptyPage (extends Component, no generic), the <div class="pane"> returned by view() is not the component's element but the first and only child of its content. So a class and id written on the tag, and this.class.add and this.attr.add inside the class, reach no element at all. An onclick written on the tag and this.motif.on inside the class, on the other hand, listen on the top-level elements of the content (see Events), and x-display and this.motif.hide() hide the content between the markers. Classes and attributes are written straight onto the div. To give the component an element of its own, declare a concrete tag (the Component<HTMLUListElement> generic, static elementTag or super('ul')): the common attributes written on the tag (class, id, style, role, aria-*, data-* …; see common attributes), DOM events (onclick …) and this.class then apply to that element, while other props such as title stay in this.props only. view() returns only the content; ActivityLog below works like that. With Component<HTMLDivElement>, returning another <div> from view() produces two nested divs.
view() or controls directly?
Content arrives in two ways:
- Return it from
view(): while the component is being built,view()is called once and its result is added as child controls. - Call
controls.add(...): on a built component the child is built right away and put in place; on a component that is not built yet, it is built together with its owner. The two work together as well:view()builds the skeleton, and content that arrives later is added withcontrols.add.
ActivityLog is a component whose element is a <ul>. It writes no view(); on every noteSaved application event (whose data is the title of the saved note) it adds a row to the list. The this.context.on subscription is removed on its own when the component is disposed (see Context):
import { Component } from "@motifx/core";
export class ActivityLog extends Component<HTMLUListElement> {
override onConfigured() {
this.class.add("log");
}
override onBuilt() {
this.context.on("noteSaved", (title?: string) => {
this.controls.add(<li>{title?.trim() || "Untitled"} saved</li>);
});
}
}
import { Component, ComponentBase, motifComponent } from "@motifx/core";
export class ActivityLog extends Component<HTMLUListElement> {
static override elementTag = "ul";
override onConfigured() {
this.class.add("log");
}
override onBuilt() {
this.context.on("noteSaved", (title?: string) => {
const row = motifComponent("li", (li: ComponentBase) => {
li.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(`${title?.trim() || "Untitled"} saved`)));
});
this.controls.add(row);
});
}
}
import { Component } from "@motifx/core";
export class ActivityLog extends Component {
static elementTag = "ul";
onConfigured() {
this.class.add("log");
}
onBuilt() {
this.context.on("noteSaved", (title) => {
this.controls.add(<li>{title?.trim() || "Untitled"} saved</li>);
});
}
}
import { Component, motifComponent } from "@motifx/core";
export class ActivityLog extends Component {
static elementTag = "ul";
onConfigured() {
this.class.add("log");
}
onBuilt() {
this.context.on("noteSaved", (title) => {
const row = motifComponent("li", (li) => {
li.controls.add(motifComponent("text", (node) => node.setText(`${title?.trim() || "Untitled"} saved`)));
});
this.controls.add(row);
});
}
}
Names of class members
Members of ComponentBase are not defined again under the same name in a class component; if they are, the member MotifJS uses is hidden. The reserved names:
- Methods and accessors:
build,dispose,disposeAsync,style,setText,setState,reState,using,doWork,getService,useModel,$,context,siblings,serviceProvider,isWait. - Objects MotifJS sets up, and state fields:
element,props,controls,class,attr,bindings,motif,parent,childs,isBuilt,isInitialized,isDisposed,isConfigured,isVisible.
view(), the lifecycle hooks (onBuilt, onDisposing …), initializeComponent and onElementCreating exist to be written in the class. If you need to extend a method such as build, dispose or style, the override calls super.build(...), super.dispose(...) and so on. An overridden dispose() runs only when the component's own dispose() is called (by hand, or through controls.remove/clear). For a component disposed together with a parent, the framework does not call the child's dispose() but uses its own internal path; in that case only the onDisposing/onDisposed hooks and the setDisposable registrations run. That is why work to do on disposal belongs in the hooks or in this.motif.setDisposable(...). Framework operations (show, hide, on, off, trigger, setDisposable …) live under this.motif, so these names are free to use in the class.
TypeScript reports a clash whose type is plainly incompatible at compile time (style = 'red' → TS2416). Defining a field with an accessor name — motif, controls, class, attr, bindings, context, siblings, serviceProvider, isWait and the state fields (isVisible …) — gives a TS2610 error whatever its type (with any or declare as well). For method names, however, it does not report an any-typed field or a method with a compatible signature (build() {}, a parameterless style()). During compilation @motifx/compiler reports an override that does not reach super on every path with an MJX015 warning: a member that never calls it, or skips it on some path because of an early return, an if with one branch, a loop or a catch (compile warnings; motif-lint runs the same rule). In development mode (app.useDevelopment()), MotifJS reports these clashes once per class with an MJX128 warning while placing the component: an override that does not call super, an instance field that hides a method or an accessor, and another value put in place of controls / attr / class / bindings / motif / element / parent. The state fields (isVisible …) and props are not checked at run time.
2. Function components
A function returns a single component. State and event handlers are kept in a closure. NoteRow is a row in the list: it takes the note and the folder through props, and reads the open note from the router's parameter.
import { useNavigation } from "@motifx/core";
import type { Folder, Note } from "../services/notes";
export function NoteRow(props: { note: Note; folder: Folder }) {
const nav = useNavigation();
const { note, folder } = props;
const open = () => nav.params.id === String(note.id);
return (
<a
rel="router"
href={`/${folder}/${note.id}`}
class={() => `row${open() ? " active" : ""}${note.title.trim() ? "" : " untitled"}`}
aria-current={() => (open() ? "page" : null)}
>
<b>
<i class="pin" aria-label="Pinned" x-display={() => note.pinned && !note.archived}></i>
<span>{() => note.title.trim() || "Untitled"}</span>
</b>
<time>{() => new Date(note.updatedAt).toLocaleDateString("tr-TR")}</time>
<p>{() => note.body.split("\n")[0] || "No text"}</p>
</a>
);
}
import { ComponentBase, motifComponent, useNavigation } from "@motifx/core";
import type { Folder, Note } from "../services/notes";
export function NoteRow(props: { note: Note; folder: Folder }) {
const nav = useNavigation();
const { note, folder } = props;
const open = () => nav.params.id === String(note.id);
return motifComponent("a", (row: ComponentBase) => {
row.attr.add({ rel: "router", href: `/${folder}/${note.id}`, "aria-current": () => (open() ? "page" : null) });
row.class.add(() => `row${open() ? " active" : ""}${note.title.trim() ? "" : " untitled"}`);
const title = motifComponent("b", (b: ComponentBase) => {
const pin = motifComponent("i", {
preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned && !note.archived),
initializeComponent: (i: ComponentBase) => {
i.class.add("pin");
i.attr.add({ "aria-label": "Pinned" });
},
});
const label = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
b.controls.add(pin, label);
});
const when = motifComponent("time", (time: ComponentBase) => time.bindings.text(() => new Date(note.updatedAt).toLocaleDateString("tr-TR")));
const preview = motifComponent("p", (p: ComponentBase) => p.bindings.text(() => note.body.split("\n")[0] || "No text"));
row.controls.add(title, when, preview);
});
}
import { useNavigation } from "@motifx/core";
export function NoteRow(props) {
const nav = useNavigation();
const { note, folder } = props;
const open = () => nav.params.id === String(note.id);
return (
<a
rel="router"
href={`/${folder}/${note.id}`}
class={() => `row${open() ? " active" : ""}${note.title.trim() ? "" : " untitled"}`}
aria-current={() => (open() ? "page" : null)}
>
<b>
<i class="pin" aria-label="Pinned" x-display={() => note.pinned && !note.archived}></i>
<span>{() => note.title.trim() || "Untitled"}</span>
</b>
<time>{() => new Date(note.updatedAt).toLocaleDateString("tr-TR")}</time>
<p>{() => note.body.split("\n")[0] || "No text"}</p>
</a>
);
}
import { motifComponent, useNavigation } from "@motifx/core";
export function NoteRow(props) {
const nav = useNavigation();
const { note, folder } = props;
const open = () => nav.params.id === String(note.id);
return motifComponent("a", (row) => {
row.attr.add({ rel: "router", href: `/${folder}/${note.id}`, "aria-current": () => (open() ? "page" : null) });
row.class.add(() => `row${open() ? " active" : ""}${note.title.trim() ? "" : " untitled"}`);
const title = motifComponent("b", (b) => {
const pin = motifComponent("i", {
preconfig: (i) => i.bindings.display(() => note.pinned && !note.archived),
initializeComponent: (i) => {
i.class.add("pin");
i.attr.add({ "aria-label": "Pinned" });
},
});
const label = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
b.controls.add(pin, label);
});
const when = motifComponent("time", (time) => time.bindings.text(() => new Date(note.updatedAt).toLocaleDateString("tr-TR")));
const preview = motifComponent("p", (p) => p.bindings.text(() => note.body.split("\n")[0] || "No text"));
row.controls.add(title, when, preview);
});
}
A function component is used in JSX the same way as a class, as a tag; without JSX, the counterpart of the tag is motifComponent(NoteRow, props). The function can also be called directly. That is an ordinary function call, and it gives the same result only for the props the function reads itself (note, folder): framework props such as ref, the common attributes (class, id …) and options are applied to the root only by motifComponent (for directives and hooks written on the tag, see below):
<NoteRow note={note} folder={this.folder} />
<NoteRow note={note} folder={folder()} />
motifComponent(NoteRow, { note, folder: this.folder });
NoteRow({ note, folder: this.folder });
motifComponent(NoteRow, { note, folder: folder() });
NoteRow({ note, folder: folder() });
<NoteRow note={note} folder={this.folder} />
<NoteRow note={note} folder={folder()} />
motifComponent(NoteRow, { note, folder: this.folder });
NoteRow({ note, folder: this.folder });
motifComponent(NoteRow, { note, folder: folder() });
NoteRow({ note, folder: folder() });
In the function component, folder() is a local function that returns the folder.
Root component
A function component has no this. Whatever a class does with this.motif, this.context, this.class or this.bindings is done in a function component through the root component. The root component is the component of the root element the function returns, and it is a Component as well. You reach it through the first argument of a hook written on the root element (onConfig={(root) => …}, initializeComponent, onBuilt) or through a ref written on the root element; if the returned JSX expression is assigned to a variable (const root = <section>…</section>), that variable is the root component too. Without JSX, the root is the component returned by the motifComponent(tag, …) call, and the hooks are written in the object form of the same call.
Hooking into the lifecycle
In function components you can hook into the lifecycle by passing the hooks as props on the returned root element (see Lifecycle). Without JSX, the hooks are written in the object form of motifComponent. SearchField takes focus once it enters the DOM; its oninput handler is written with two parameters, so the first one is the field's component:
export function SearchField(props: { onQueryChange: (query: string) => void }) {
return (
<input
type="search"
placeholder="Search notes"
onMounted={(field) => field.element.focus()}
oninput={(field, e) => props.onQueryChange(field.element.value)}
/>
);
}
import { ComponentBase, motifComponent } from "@motifx/core";
export function SearchField(props: { onQueryChange: (query: string) => void }) {
return motifComponent("input", {
onMounted: (field: ComponentBase) => field.element.focus(),
initializeComponent: (field: ComponentBase) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.motif.on("input", (sender: ComponentBase, e: Event) => props.onQueryChange(sender.element.value));
},
});
}
export function SearchField(props) {
return (
<input
type="search"
placeholder="Search notes"
onMounted={(field) => field.element.focus()}
oninput={(field, e) => props.onQueryChange(field.element.value)}
/>
);
}
import { motifComponent } from "@motifx/core";
export function SearchField(props) {
return motifComponent("input", {
onMounted: (field) => field.element.focus(),
initializeComponent: (field) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.motif.on("input", (sender, e) => props.onQueryChange(sender.element.value));
},
});
}
Directives and hooks written on the tag
Framework props written on the tag of a function component — directives such as x-display and x-wait, lifecycle hooks such as onConfig/onBuilt/x-mounted/x-initializing (in both the x- and x: spellings), initializeComponent and ref — are applied to the root the function returns. Without JSX, the same keys go into the object passed to motifComponent; instead of x-display and x-wait you can also use bindings.display / bindings.wait inside preconfig. These keys and ref are not part of the props passed to the function, nor of a class's this.props:
<NoteRow x-display={() => !note.archived} note={note} folder={this.folder} />
<NoteRow x-display={() => !note.archived} note={note} folder={folder()} />
motifComponent(NoteRow, {
note,
folder: this.folder,
preconfig: (row: ComponentBase) => row.bindings.display(() => !note.archived),
});
motifComponent(NoteRow, {
note,
folder: folder(),
preconfig: (row: ComponentBase) => row.bindings.display(() => !note.archived),
});
<NoteRow x-display={() => !note.archived} note={note} folder={this.folder} />
<NoteRow x-display={() => !note.archived} note={note} folder={folder()} />
motifComponent(NoteRow, {
note,
folder: this.folder,
preconfig: (row) => row.bindings.display(() => !note.archived),
});
motifComponent(NoteRow, {
note,
folder: folder(),
preconfig: (row) => row.bindings.display(() => !note.archived),
});
The function does not have to do anything for this, and it does not have to pass the props on to its root either. (If the function already passes the props to its root — motifComponent('div', props) — the framework props are not applied a second time.) In class components the same props go to the constructor.
3. Options API components
You return an object wrapped with motifOptions: el, data, view and an optional ctor. motifOptions returns the object as it is; it only tells TypeScript that this inside the methods is the component, so this.class, this.motif and this.props compile in strict mode and the editor can complete them. The function that returns the object is used like a component: in JSX (<NoteFilter />), without JSX with motifComponent(NoteFilter, props), as a route's control, and as the default loaded by Lazy. NoteFilter is a search box that takes its initial query from the factory's props parameter inside ctor. The factory's parameter also declares the tag's prop type (<NoteFilter initial="…" />):
import { motifOptions, reactive } from "@motifx/core";
export const NoteFilter = (props?: { initial?: string }) => motifOptions({
el: "div",
data: reactive({ query: "" }),
view() {
this.class.add("search");
return (
<>
<input type="search" placeholder="Search notes" x-model={() => this.data.query} />
<button type="button" x-display={() => this.data.query !== ""} onclick={() => (this.data.query = "")}>Clear</button>
</>
);
},
ctor() {
if (props?.initial) this.data.query = props.initial;
},
});
import { ComponentBase, motifComponent, motifOptions, reactive } from "@motifx/core";
export const NoteFilter = (props?: { initial?: string }) => motifOptions({
el: "div",
data: reactive({ query: "" }),
view() {
this.class.add("search");
const field = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.model(() => this.data.query, (value: string) => (this.data.query = value));
});
const clear = motifComponent("button", {
preconfig: (button: ComponentBase) => button.bindings.display(() => this.data.query !== ""),
initializeComponent: (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => (this.data.query = ""));
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Clear")));
},
});
return [field, clear];
},
ctor() {
if (props?.initial) this.data.query = props.initial;
},
});
import { motifOptions, reactive } from "@motifx/core";
export const NoteFilter = (props) => motifOptions({
el: "div",
data: reactive({ query: "" }),
view() {
this.class.add("search");
return (
<>
<input type="search" placeholder="Search notes" x-model={() => this.data.query} />
<button type="button" x-display={() => this.data.query !== ""} onclick={() => (this.data.query = "")}>Clear</button>
</>
);
},
ctor() {
if (props?.initial) this.data.query = props.initial;
},
});
import { motifComponent, motifOptions, reactive } from "@motifx/core";
export const NoteFilter = (props) => motifOptions({
el: "div",
data: reactive({ query: "" }),
view() {
this.class.add("search");
const field = motifComponent("input", (input) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.model(() => this.data.query, (value) => (this.data.query = value));
});
const clear = motifComponent("button", {
preconfig: (button) => button.bindings.display(() => this.data.query !== ""),
initializeComponent: (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => (this.data.query = ""));
button.controls.add(motifComponent("text", (node) => node.setText("Clear")));
},
});
return [field, clear];
},
ctor() {
if (props?.initial) this.data.query = props.initial;
},
});
<NoteFilter initial="meeting" />
motifComponent(NoteFilter, { initial: "meeting" });
<NoteFilter initial="meeting" />
motifComponent(NoteFilter, { initial: "meeting" });
Every field of the object except el is copied onto the component. ctor is not copied; it is called once, right after the component is created (before build()/view()), with this as the component and the props written on the tag as its only argument. An error thrown inside it does not break the creation of the component; it is reported to the central error handler as MJX122. The props written on the tag can also be reached as this.props. el can be a tag name ('div'), a DOM node or another component (a class, a function, an Options object); when a component is given, its root is used.
el is the component's own element, and view() returns the content: a fragment (<>…</>) in JSX, an array without JSX. To give the root a class, write this.class.add(...) inside view() (or in ctor/onConfigured), and this.attr.add({ ... }) for attributes. Returning a wrapper with the same tag as el from view() produces two nested elements.
get/set accessors on the object are carried over to the component as accessors: they do not run during copying, and they run again on every read with this as the component. You can write a derived value as an accessor (get visible() { … }) or as a method (visible() { … }). Of the lifecycle methods written on the object, onConfigured, onBuilding, initializeComponent, onBuilt, onMounted, onVisibilityChanged, onActivated, onDeactivated, onDisposing and onDisposed run. onInitializing and onInitialized do not run: those two phases end while el is being created, before the fields are copied from the object. An onConfig written on the object runs only when el is a class component (a class component's configuration phase is postponed to the start of build(), just before onConfigured); when el is a tag name, a DOM node or a function that returns a plain element, this phase also ends while the element is being created, and the onConfig on the object does not run. An onConfig={…} prop written on the tag does run, but when el is a tag name it runs while the root is being created: at that moment the object's members (data, view, methods) have not been copied yet, ctor has not run and parent is null. Put setup code that touches the members in ctor or onConfigured.
Next step
For the ways of creating a component in code, go on to Creating Components.