Core concepts
Lifecycle
The order of hooks, hooks as class methods and JSX props, initializeComponent, visibility, waiting, disposal and the application lifecycle; with and without JSX.
Every MotifJS component goes through a set lifecycle, from its creation to being attached to the DOM and being disposed. You define the hooks that run at these stages as a class method, a JSX prop, or, without JSX, a key in the object form of motifComponent.
The examples are slices of a notes application. Note and NotesService are the ones in the src/services/notes.ts file on the Components page; this is the service that pulls notes from the server:
import { Injectable } from "@motifx/core";
import type { Note } from "./notes";
@Injectable({ lifetime: "singleton" })
export class SyncService {
async pull(): Promise<Note[]> {
const response = await fetch("/api/notes");
return response.json();
}
}
import { Injectable } from "@motifx/core";
@Injectable({ lifetime: "singleton" })
export class SyncService {
async pull() {
const response = await fetch("/api/notes");
return response.json();
}
}
Hooks and run order
| Order | Hook | When it runs |
|---|---|---|
| 1 | onInitializing |
In the constructor, after the element has been assigned and the props processed (this.props is ready; fields of the subclass such as state = reactive(...) have not been initialized yet). |
| 2 | onInitialized |
In the constructor, right after onInitializing. |
| 3 | onConfig |
The configuration stage; props are ready, not attached to the DOM yet. A good place to fetch data. In subclasses it runs at the start of build() (after the class fields are initialized). |
| 4 | onConfigured |
When configuration is complete (the start of the build). |
| 5 | onBuilding |
As DOM construction starts. |
| 6 | initializeComponent |
Right before view() is called; the component's setup code (the compiler produces it from JSX, and without JSX you write it). |
| 7 | oninitializeComponent |
Right after all initializeComponent code (the class method, the value on the tag, the one the compiler produced) has run, before view(). |
| 8 | onBuilt |
After the component and its children have been built. At this moment the element may still be inside a detached fragment (ternary/list branches). |
| 9 | onMounted |
Once, in the task after the component has been placed in a tree attached to a root on the page; the screen may have been painted by then. The root is the target of app.run or a component wrapping an element already in the document, and every component along the way must be visible. It is only for work that needs the element to really be on the page and visible: focus(), measurements, libraries that want the element in the document. Other setup work goes in onBuilt. |
| — | onActivated / onDeactivated |
When the component is detached from the DOM (without being disposed) and placed again: returning to a keepAlive route, controls.add after controls.detach/silentDetach (moving included), motif.hide()/motif.show() (x-wait/x-display included), a Virtualization row leaving the window and coming back. onDeactivated on leaving, onActivated on coming back. It does not run on the first show. It is passed on to the visible subtree too (parent first); a hidden child is not activated together with its parent, it is activated by its own motif.show(). |
| — | onVisibilityChanged |
When motif.show()/motif.hide()/motif.toggle() (x-wait/x-display included) change visibility; it runs before isVisible is updated (when hiding, after the leave animation finishes; with hideStrategy: "detach", before the animation), so inside the hook isVisible shows the previous value and e.visible the new one. |
| — | onDisposing |
As disposal starts. |
| — | onDisposed |
When disposal is complete. |
onInitializingandonInitializedrun while the component is being created (in the constructor flow).onConfigruns in the constructor for plainnew Component(tag, { onConfig })instances; in subclasses (class X extends Component) it runs after class fields such asstate = reactive(...)have been initialized, at the start ofbuild(), right beforeonConfigured(a subclass instance that is never built does not getonConfig).onConfigured,onBuilding,initializeComponent,oninitializeComponentandonBuiltrun duringbuild();onMountedruns in the task after the component has been placed in a tree attached to the root (also in the next task if the tree is already attached at the moment it is placed).isInitializedisfalseduringonInitializingandtruefromonInitializedon (it is set when the initialization in the constructor finishes).
onMountedhooks are queued and run after the task that did the setup, in the order they were registered. The queue is processed in 8 ms slices: when the time is up, the remaining hooks are left for the next task, and in between the browser paints the screen and handles user input. That way, setting up many components that wrap a chart or table library does not lock up the page. A child component's hook runs before its parent's, and the hook of a component disposed before its turn does not run. The core follows the placing:controls.add, the end of anx-wait,motif.show()andTransporterput the hook in the queue; if you put an element into the document yourself withappendChild, the hook does not run. To give content to an element that already exists, wrap the element withnew Component(element)and add the children withcontrols.add; the body of a window opened withwindow.openis wrapped the same way, and the components given to that window are attached there. The queue runs on the timer of the window the root is in.
State flags: isInitialized, isConfigured, isBuilt, isVisible, isWait and isDisposed are updated when the corresponding phase has passed, and are read from the component.
onInitializing, onInitialized and onConfig written on an Options API object do not run (these phases end before the fields are copied from the object); for setup use ctor or onConfigured (see Options API).
Defining hooks
In a class a hook is a method; in a function component it is a prop written on the returned root, and in the Options API it is a member of the object. Without JSX the class method stays the same, and for functions and plain tags the hook is a key in the object form of motifComponent. NoteBoard fetches the notes while it is being configured (onConfig), focuses the search box once it enters the DOM (onMounted) and refreshes once a minute; the timer is tied to the component with motif.setDisposable and stops on its own during disposal:
import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export class NoteBoard extends Component {
notes = this.getService(NotesService)!;
sync = this.getService(SyncService)!;
state = reactive({ loading: true });
search!: Component<HTMLInputElement>;
override async onConfig() {
this.notes.state.items = await this.sync.pull();
this.state.loading = false;
const timer = setInterval(async () => (this.notes.state.items = await this.sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
}
override onMounted() {
this.search.element.focus();
}
override view() {
return (
<section class="board">
<input type="search" placeholder="Search notes" ref={this.search} />
<p class="loading" x-display={() => this.state.loading}>Loading…</p>
<ul>{this.notes.list("notes").map((note) => <li key={note.id}>{note.title}</li>)}</ul>
</section>
);
}
}
import { Component, FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export function NoteBoard() {
const notes = FromService(NotesService)!;
const sync = FromService(SyncService)!;
const state = reactive({ loading: true });
let search!: Component<HTMLInputElement>;
return (
<section
class="board"
onConfig={async (board) => {
notes.state.items = await sync.pull();
state.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
board.motif.setDisposable(() => clearInterval(timer));
}}
onMounted={() => search.element.focus()}
>
<input type="search" placeholder="Search notes" ref={(field) => (search = field)} />
<p class="loading" x-display={() => state.loading}>Loading…</p>
<ul>{notes.list("notes").map((note) => <li key={note.id}>{note.title}</li>)}</ul>
</section>
);
}
import { Component, FromService, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export const NoteBoard = () => motifOptions({
el: "section",
data: reactive({ loading: true }),
search: undefined as Component<HTMLInputElement> | undefined,
async onConfigured() {
const notes = FromService(NotesService)!;
const sync = FromService(SyncService)!;
this.class.add("board");
notes.state.items = await sync.pull();
this.data.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
},
onMounted() {
this.search!.element.focus();
},
view() {
const notes = FromService(NotesService)!;
return (
<>
<input type="search" placeholder="Search notes" ref={(field: Component<HTMLInputElement>) => (this.search = field)} />
<p class="loading" x-display={() => this.data.loading}>Loading…</p>
<ul>{notes.list("notes").map((note) => <li key={note.id}>{note.title}</li>)}</ul>
</>
);
},
});
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
import { Note, NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export class NoteBoard extends Component {
notes = this.getService(NotesService)!;
sync = this.getService(SyncService)!;
state = reactive({ loading: true });
search!: Component<HTMLInputElement>;
override async onConfig() {
this.notes.state.items = await this.sync.pull();
this.state.loading = false;
const timer = setInterval(async () => (this.notes.state.items = await this.sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
}
override onMounted() {
this.search.element.focus();
}
override view() {
return motifComponent("section", (board: ComponentBase) => {
board.class.add("board");
const search = motifComponent("input", {
ref: (field: Component<HTMLInputElement>) => (this.search = field),
initializeComponent: (field: ComponentBase) => field.attr.add({ type: "search", placeholder: "Search notes" }),
});
const loading = motifComponent("p", {
preconfig: (p: ComponentBase) => p.bindings.display(() => this.state.loading),
initializeComponent: (p: ComponentBase) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Loading…")));
},
});
const list = motifComponent("ul", (ul: ComponentBase) => ul.controls.add(motifComponent({
initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => this.notes.list("notes"), (note: Note) => motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))),
})),
})));
board.controls.add(search, loading, list);
});
}
}
import { Component, ComponentBase, FromService, motifComponent, reactive } from "@motifx/core";
import { Note, NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export function NoteBoard() {
const notes = FromService(NotesService)!;
const sync = FromService(SyncService)!;
const state = reactive({ loading: true });
let search!: Component<HTMLInputElement>;
return motifComponent("section", {
onConfig: async (board: ComponentBase) => {
notes.state.items = await sync.pull();
state.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
board.motif.setDisposable(() => clearInterval(timer));
},
onMounted: () => search.element.focus(),
initializeComponent: (board: ComponentBase) => {
board.class.add("board");
const field = motifComponent("input", {
ref: (input: Component<HTMLInputElement>) => (search = input),
initializeComponent: (input: ComponentBase) => input.attr.add({ type: "search", placeholder: "Search notes" }),
});
const loading = motifComponent("p", {
preconfig: (p: ComponentBase) => p.bindings.display(() => state.loading),
initializeComponent: (p: ComponentBase) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Loading…")));
},
});
const list = motifComponent("ul", (ul: ComponentBase) => ul.controls.add(motifComponent({
initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("notes"), (note: Note) => motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))),
})),
})));
board.controls.add(field, loading, list);
},
});
}
import { Component, ComponentBase, FromService, motifComponent, motifOptions, reactive } from "@motifx/core";
import { Note, NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export const NoteBoard = () => motifOptions({
el: "section",
data: reactive({ loading: true }),
search: undefined as Component<HTMLInputElement> | undefined,
async onConfigured() {
const notes = FromService(NotesService)!;
const sync = FromService(SyncService)!;
this.class.add("board");
notes.state.items = await sync.pull();
this.data.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
},
onMounted() {
this.search!.element.focus();
},
view() {
const notes = FromService(NotesService)!;
const search = motifComponent("input", {
ref: (field: Component<HTMLInputElement>) => (this.search = field),
initializeComponent: (field: ComponentBase) => field.attr.add({ type: "search", placeholder: "Search notes" }),
});
const loading = motifComponent("p", {
preconfig: (p: ComponentBase) => p.bindings.display(() => this.data.loading),
initializeComponent: (p: ComponentBase) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Loading…")));
},
});
const list = motifComponent("ul", (ul: ComponentBase) => ul.controls.add(motifComponent({
initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => notes.list("notes"), (note: Note) => motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))),
})),
})));
return [search, loading, list];
},
});
import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export class NoteBoard extends Component {
notes = this.getService(NotesService);
sync = this.getService(SyncService);
state = reactive({ loading: true });
search;
async onConfig() {
this.notes.state.items = await this.sync.pull();
this.state.loading = false;
const timer = setInterval(async () => (this.notes.state.items = await this.sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
}
onMounted() {
this.search.element.focus();
}
view() {
return (
<section class="board">
<input type="search" placeholder="Search notes" ref={this.search} />
<p class="loading" x-display={() => this.state.loading}>Loading…</p>
<ul>{this.notes.list("notes").map((note) => <li key={note.id}>{note.title}</li>)}</ul>
</section>
);
}
}
import { FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export function NoteBoard() {
const notes = FromService(NotesService);
const sync = FromService(SyncService);
const state = reactive({ loading: true });
let search;
return (
<section
class="board"
onConfig={async (board) => {
notes.state.items = await sync.pull();
state.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
board.motif.setDisposable(() => clearInterval(timer));
}}
onMounted={() => search.element.focus()}
>
<input type="search" placeholder="Search notes" ref={(field) => (search = field)} />
<p class="loading" x-display={() => state.loading}>Loading…</p>
<ul>{notes.list("notes").map((note) => <li key={note.id}>{note.title}</li>)}</ul>
</section>
);
}
import { FromService, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export const NoteBoard = () => motifOptions({
el: "section",
data: reactive({ loading: true }),
search: undefined,
async onConfigured() {
const notes = FromService(NotesService);
const sync = FromService(SyncService);
this.class.add("board");
notes.state.items = await sync.pull();
this.data.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
},
onMounted() {
this.search.element.focus();
},
view() {
const notes = FromService(NotesService);
return (
<>
<input type="search" placeholder="Search notes" ref={(field) => (this.search = field)} />
<p class="loading" x-display={() => this.data.loading}>Loading…</p>
<ul>{notes.list("notes").map((note) => <li key={note.id}>{note.title}</li>)}</ul>
</>
);
},
});
import { Component, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export class NoteBoard extends Component {
notes = this.getService(NotesService);
sync = this.getService(SyncService);
state = reactive({ loading: true });
search;
async onConfig() {
this.notes.state.items = await this.sync.pull();
this.state.loading = false;
const timer = setInterval(async () => (this.notes.state.items = await this.sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
}
onMounted() {
this.search.element.focus();
}
view() {
return motifComponent("section", (board) => {
board.class.add("board");
const search = motifComponent("input", {
ref: (field) => (this.search = field),
initializeComponent: (field) => field.attr.add({ type: "search", placeholder: "Search notes" }),
});
const loading = motifComponent("p", {
preconfig: (p) => p.bindings.display(() => this.state.loading),
initializeComponent: (p) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node) => node.setText("Loading…")));
},
});
const list = motifComponent("ul", (ul) => ul.controls.add(motifComponent({
initializeComponent: (holder) => holder.bindings.list(() => this.notes.list("notes"), (note) => motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li) => li.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))),
})),
})));
board.controls.add(search, loading, list);
});
}
}
import { FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export function NoteBoard() {
const notes = FromService(NotesService);
const sync = FromService(SyncService);
const state = reactive({ loading: true });
let search;
return motifComponent("section", {
onConfig: async (board) => {
notes.state.items = await sync.pull();
state.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
board.motif.setDisposable(() => clearInterval(timer));
},
onMounted: () => search.element.focus(),
initializeComponent: (board) => {
board.class.add("board");
const field = motifComponent("input", {
ref: (input) => (search = input),
initializeComponent: (input) => input.attr.add({ type: "search", placeholder: "Search notes" }),
});
const loading = motifComponent("p", {
preconfig: (p) => p.bindings.display(() => state.loading),
initializeComponent: (p) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node) => node.setText("Loading…")));
},
});
const list = motifComponent("ul", (ul) => ul.controls.add(motifComponent({
initializeComponent: (holder) => holder.bindings.list(() => notes.list("notes"), (note) => motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li) => li.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))),
})),
})));
board.controls.add(field, loading, list);
},
});
}
import { FromService, motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
import { SyncService } from "../services/sync";
export const NoteBoard = () => motifOptions({
el: "section",
data: reactive({ loading: true }),
search: undefined,
async onConfigured() {
const notes = FromService(NotesService);
const sync = FromService(SyncService);
this.class.add("board");
notes.state.items = await sync.pull();
this.data.loading = false;
const timer = setInterval(async () => (notes.state.items = await sync.pull()), 60_000);
this.motif.setDisposable(() => clearInterval(timer));
},
onMounted() {
this.search.element.focus();
},
view() {
const notes = FromService(NotesService);
const search = motifComponent("input", {
ref: (field) => (this.search = field),
initializeComponent: (field) => field.attr.add({ type: "search", placeholder: "Search notes" }),
});
const loading = motifComponent("p", {
preconfig: (p) => p.bindings.display(() => this.data.loading),
initializeComponent: (p) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node) => node.setText("Loading…")));
},
});
const list = motifComponent("ul", (ul) => ul.controls.add(motifComponent({
initializeComponent: (holder) => holder.bindings.list(() => notes.list("notes"), (note) => motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li) => li.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))),
})),
})));
return [search, loading, list];
},
});
Every hook is called with the (sender, e) signature: sender is the component itself and e is EventArgs ({ cancel: boolean }). In a function component sender is the returned root; the NoteBoard written as a function ties the timer to this root's motif.setDisposable.
An error thrown in a hook, or the rejection of a Promise returned by a hook (async hooks included), is reported as MotifError MJX122: it is written to console.error (unless app.useLogging(false)) and passed to the errorHandler.addListener(fn) listeners; development mode is not required, it works in production too. The component keeps being built. This rule also applies to initializeComponent, oninitializeComponent, the ctor of an Options API object, the ref callback (on a plain tag and on a class component tag; the component is still created) and lifecycle listeners added in code with motif.on("x:built", fn) (x:mounted included).
Hooks on a tag
A hook can be written as a prop on any tag, and it is attached to that tag's component. Without JSX the same names are keys of the object form. In the example, onMounted is given to a plain tag and onDisposing to a component tag (TitleSync, described below):
<input type="search" placeholder="Search notes" onMounted={(field) => field.element.focus()} />
<TitleSync onDisposing={() => (document.title = "Notes")} />
const search = motifComponent("input", {
onMounted: (field: ComponentBase) => field.element.focus(),
initializeComponent: (field: ComponentBase) => field.attr.add({ type: "search", placeholder: "Search notes" }),
});
const title = motifComponent(TitleSync, { onDisposing: () => (document.title = "Notes") });
<input type="search" placeholder="Search notes" onMounted={(field) => field.element.focus()} />
<TitleSync onDisposing={() => (document.title = "Notes")} />
const search = motifComponent("input", {
onMounted: (field) => field.element.focus(),
initializeComponent: (field) => field.attr.add({ type: "search", placeholder: "Search notes" }),
});
const title = motifComponent(TitleSync, { onDisposing: () => (document.title = "Notes") });
The x- and x: forms are accepted too: <ul x-config={…} x:built={…}>. In an object without JSX the x- form works as a key ({ "x-config": … }); only JSX recognizes the x: form.
Important: lifecycle handlers you give as props do not replace the class methods; both run (prop handlers are collected and called in addition to the class method).
Several spellings of a hook on the same tag (on<hook>, x-<hook>, x:<hook>; differences in case included, e.g. onBuilt and onbuilt) are combined: each runs once, in source order. Without JSX a key can also be given an array of functions (onBuilt: [a, b]); they run in the order of the array. This is the same on a plain DOM tag and on a component tag; on a function component tag the hooks, whether written once or combined, are applied to the root the function returns. The initializing/initialized hooks on a function component tag run as soon as the root is built, in the same order as on a class component tag: ref → initializing → initialized → config → … → building → initializeComponent → built. This rule holds for all of the built, building, mounted, config, configured, initializing, initialized, disposing, disposed, visibilitychanged, activated and deactivated hooks:
<ul onBuilt={() => console.log("before")} x-built={() => console.log("after")} />
const list = motifComponent("ul", { onBuilt: [() => console.log("before"), () => console.log("after")] });
<ul onBuilt={() => console.log("before")} x-built={() => console.log("after")} />
const list = motifComponent("ul", { onBuilt: [() => console.log("before"), () => console.log("after")] });
initializeComponent — the component's setup code
initializeComponent runs once per component, right before view(). The compiler passes the setup code it produces from JSX (the attributes written on the tag, events, directives, placing the children) under this name; without JSX, the second argument of the motifComponent(tag, setup) call is exactly this. In a component that uses JSX it is also used to add a binding or to prepare children in code. TitleSync keeps the tab title in step with the number of notes:
import { Component, ComponentBase } from "@motifx/core";
import { NotesService } from "../services/notes";
export class TitleSync extends Component<HTMLSpanElement> {
notes = this.getService(NotesService)!;
override initializeComponent(sender: ComponentBase) {
sender.bindings.watch(() => (document.title = `${this.notes.count("notes")} notes · Notes`));
}
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";
export function TitleSync() {
const notes = FromService(NotesService)!;
return <span initializeComponent={(sender) => sender.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`))} />;
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";
export const TitleSync = () => motifOptions({
el: "span",
initializeComponent() {
const notes = FromService(NotesService)!;
this.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`));
},
});
import { Component, ComponentBase } from "@motifx/core";
import { NotesService } from "../services/notes";
export class TitleSync extends Component<HTMLSpanElement> {
static override elementTag = "span";
notes = this.getService(NotesService)!;
override initializeComponent(sender: ComponentBase) {
sender.bindings.watch(() => (document.title = `${this.notes.count("notes")} notes · Notes`));
}
}
import { ComponentBase, FromService, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";
export function TitleSync() {
const notes = FromService(NotesService)!;
return motifComponent("span", (sender: ComponentBase) => sender.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`)));
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";
export const TitleSync = () => motifOptions({
el: "span",
initializeComponent() {
const notes = FromService(NotesService)!;
this.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`));
},
});
import { Component } from "@motifx/core";
import { NotesService } from "../services/notes";
export class TitleSync extends Component {
static elementTag = "span";
notes = this.getService(NotesService);
initializeComponent(sender) {
sender.bindings.watch(() => (document.title = `${this.notes.count("notes")} notes · Notes`));
}
}
import { FromService } from "@motifx/core";
import { NotesService } from "../services/notes";
export function TitleSync() {
const notes = FromService(NotesService);
return <span initializeComponent={(sender) => sender.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`))} />;
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";
export const TitleSync = () => motifOptions({
el: "span",
initializeComponent() {
const notes = FromService(NotesService);
this.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`));
},
});
import { Component } from "@motifx/core";
import { NotesService } from "../services/notes";
export class TitleSync extends Component {
static elementTag = "span";
notes = this.getService(NotesService);
initializeComponent(sender) {
sender.bindings.watch(() => (document.title = `${this.notes.count("notes")} notes · Notes`));
}
}
import { FromService, motifComponent } from "@motifx/core";
import { NotesService } from "../services/notes";
export function TitleSync() {
const notes = FromService(NotesService);
return motifComponent("span", (sender) => sender.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`)));
}
import { FromService, motifOptions } from "@motifx/core";
import { NotesService } from "../services/notes";
export const TitleSync = () => motifOptions({
el: "span",
initializeComponent() {
const notes = FromService(NotesService);
this.bindings.watch(() => (document.title = `${notes.count("notes")} notes · Notes`));
},
});
initializeComponent can also be written as a prop on a tag; the tag's component is given as sender, and it is called once during setup. On a plain DOM tag, on a class component tag and on a function component tag (on the root the function returns), its meaning and timing are the same:
<p class="count" initializeComponent={(count) => count.bindings.text(() => `${notes.count("notes")} notes`)} />
<TitleSync initializeComponent={(title) => title.motif.setDisposable(() => (document.title = "Notes"))} />
const count = motifComponent("p", (count: ComponentBase) => {
count.class.add("count");
count.bindings.text(() => `${notes.count("notes")} notes`);
});
const title = motifComponent(TitleSync, { initializeComponent: (title: ComponentBase) => title.motif.setDisposable(() => (document.title = "Notes")) });
<p class="count" initializeComponent={(count) => count.bindings.text(() => `${notes.count("notes")} notes`)} />
<TitleSync initializeComponent={(title) => title.motif.setDisposable(() => (document.title = "Notes"))} />
const count = motifComponent("p", (count) => {
count.class.add("count");
count.bindings.text(() => `${notes.count("notes")} notes`);
});
const title = motifComponent(TitleSync, { initializeComponent: (title) => title.motif.setDisposable(() => (document.title = "Notes")) });
Both the one the user gives and the one the compiler produces run; the order is: the component's initializeComponent method, the one given on the tag, the one the compiler produced.
oninitializeComponent — right after the setup code
In a component that uses JSX, the initializeComponent stage is filled by the compiler's setup code. oninitializeComponent is the hook that runs in the same stage, right after that code finishes. At this moment the attributes, events, bindings and children have been defined, and view() has not been called yet. This is the place for code that wants to add its own setup on top of the setup the compiler produced.
The order inside build():
- The class's
initializeComponentmethod. - The
initializeComponentvalues from the tag or from the compiler. - The class's
oninitializeComponentmethod. - The
oninitializeComponentvalues from the tag. view().
As a class method (a member with the same name on an Options API object runs at the same point):
import { Component, ComponentBase, EventArgs } from "@motifx/core";
export class TagList extends Component<HTMLUListElement> {
oninitializeComponent(sender: ComponentBase, e: EventArgs) {
sender.class.add(sender.controls.items.length > 5 ? "tags tags--many" : "tags");
}
}
import { Component, ComponentBase, EventArgs } from "@motifx/core";
export class TagList extends Component<HTMLUListElement> {
static override elementTag = "ul";
oninitializeComponent(sender: ComponentBase, e: EventArgs) {
sender.class.add(sender.controls.items.length > 5 ? "tags tags--many" : "tags");
}
}
import { Component } from "@motifx/core";
export class TagList extends Component {
static elementTag = "ul";
oninitializeComponent(sender, e) {
sender.class.add(sender.controls.items.length > 5 ? "tags tags--many" : "tags");
}
}
import { Component } from "@motifx/core";
export class TagList extends Component {
static elementTag = "ul";
oninitializeComponent(sender, e) {
sender.class.add(sender.controls.items.length > 5 ? "tags tags--many" : "tags");
}
}
As a prop on a tag (a plain DOM tag, a class component tag and a function component tag; on a function component it is applied to the root the function returns). When this hook runs, the children written on the tag have been added to controls; without JSX it is a key of the object form, and it runs after the children added by the setup function or given with childs:
<ul oninitializeComponent={(list) => list.class.add(list.controls.items.length > 5 ? "tags tags--many" : "tags")}>
<li>work</li>
<li>ev</li>
</ul>
<TagList oninitializeComponent={(tags) => tags.attr.add({ "aria-label": `${tags.controls.items.length} tags` })}>
<li>work</li>
<li>ev</li>
</TagList>
const plain = motifComponent("ul", {
oninitializeComponent: (list: ComponentBase) => list.class.add(list.controls.items.length > 5 ? "tags tags--many" : "tags"),
initializeComponent: (list: ComponentBase) => {
for (const tag of ["work", "home"]) {
list.controls.add(motifComponent("li", (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(tag)))));
}
},
});
const labelled = motifComponent(TagList, {
oninitializeComponent: (tags: ComponentBase) => tags.attr.add({ "aria-label": `${tags.controls.items.length} tags` }),
childs: ["work", "home"].map((tag) => motifComponent("li", (li: ComponentBase) => li.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(tag))))),
});
<ul oninitializeComponent={(list) => list.class.add(list.controls.items.length > 5 ? "tags tags--many" : "tags")}>
<li>work</li>
<li>ev</li>
</ul>
<TagList oninitializeComponent={(tags) => tags.attr.add({ "aria-label": `${tags.controls.items.length} tags` })}>
<li>work</li>
<li>ev</li>
</TagList>
const plain = motifComponent("ul", {
oninitializeComponent: (list) => list.class.add(list.controls.items.length > 5 ? "tags tags--many" : "tags"),
initializeComponent: (list) => {
for (const tag of ["work", "home"]) {
list.controls.add(motifComponent("li", (li) => li.controls.add(motifComponent("text", (node) => node.setText(tag)))));
}
},
});
const labelled = motifComponent(TagList, {
oninitializeComponent: (tags) => tags.attr.add({ "aria-label": `${tags.controls.items.length} tags` }),
childs: ["work", "home"].map((tag) => motifComponent("li", (li) => li.controls.add(motifComponent("text", (node) => node.setText(tag))))),
});
Visibility — motif.show / motif.hide / motif.toggle
You can hide and show a component without disposing it:
await this.motif.hide(); // hide (plays the leave animation if there is one)
await this.motif.show(); // show (with the enter animation if there is one)
this.motif.toggle(); // flip it
this.isVisible; // current visibility
await panel.motif.hide(); // hide (plays the leave animation if there is one)
await panel.motif.show(); // show (with the enter animation if there is one)
panel.motif.toggle(); // flip it
panel.isVisible; // current visibility
await this.motif.hide(); // hide (plays the leave animation if there is one)
await this.motif.show(); // show (with the enter animation if there is one)
this.motif.toggle(); // flip it
this.isVisible; // current visibility
await panel.motif.hide(); // hide (plays the leave animation if there is one)
await panel.motif.show(); // show (with the enter animation if there is one)
panel.motif.toggle(); // flip it
panel.isVisible; // current visibility
In a function component these calls are made on the component itself (panel in the example): in JSX, the component that reaches the root's onConfig hook or ref, and without JSX, the component received by the function of the motifComponent call that builds the root.
When visibility changes, onVisibilityChanged(sender, e) runs; the hook is called before isVisible is updated. The new state is in the event argument: e.visible is true while showing and false while hiding, while isVisible gives the previous state at that moment. The same argument also reaches the onVisibilityChanged prop and a motif.on("x:visibilityChanged", …) listener. A hidden component and its subtree get onDeactivated, and onActivated when shown again. Bindings keep running while it is hidden, and the component appears with the current value when shown. The same order applies to a component without an element (a fragment): the children's elements swap places with their own markers (<!--h-->) and play their own leave/enter transitions, and the notification goes to the fragment first and then to the visible children; the children's own isVisible does not change, a child hidden separately stays hidden when the fragment is shown, and a child added while the fragment is hidden (new, built earlier or moved from another parent) stays hidden as well; its place is set by its order in controls, and it takes that place when the fragment is shown.
Hide strategy — hideStrategy
hideStrategy decides how a hidden component is handled in the DOM:
| Value | Behaviour |
|---|---|
'placeholder' |
The element leaves the DOM and a <!--h--> comment marker is put in its place; when showing, it swaps with the marker. controls.move and list sorting move the marker together with the component. |
'detach' |
Removed from the DOM without leaving a trace; when shown, it is added again according to its order in the parent. |
'auto' (default) |
Behaves like placeholder; every component, .map rows and items given a key included, leaves a marker in its place. |
The strategy is given with options; it is a prop in JSX and a key of the object form without JSX. In code, write it to the motif.options.hideStrategy field:
<p class="loading" options={{ hideStrategy: "detach" }} x-display={() => this.state.loading}>Loading…</p>
<p class="loading" options={{ hideStrategy: "detach" }} x-display={() => state.loading}>Loading…</p>
const loading = motifComponent("p", {
options: { hideStrategy: "detach" },
preconfig: (p: ComponentBase) => p.bindings.display(() => this.state.loading),
initializeComponent: (p: ComponentBase) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Loading…")));
},
});
const loading = motifComponent("p", {
options: { hideStrategy: "detach" },
preconfig: (p: ComponentBase) => p.bindings.display(() => state.loading),
initializeComponent: (p: ComponentBase) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Loading…")));
},
});
<p class="loading" options={{ hideStrategy: "detach" }} x-display={() => this.state.loading}>Loading…</p>
<p class="loading" options={{ hideStrategy: "detach" }} x-display={() => state.loading}>Loading…</p>
const loading = motifComponent("p", {
options: { hideStrategy: "detach" },
preconfig: (p) => p.bindings.display(() => this.state.loading),
initializeComponent: (p) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node) => node.setText("Loading…")));
},
});
const loading = motifComponent("p", {
options: { hideStrategy: "detach" },
preconfig: (p) => p.bindings.display(() => state.loading),
initializeComponent: (p) => {
p.class.add("loading");
p.controls.add(motifComponent("text", (node) => node.setText("Loading…")));
},
});
this.motif.options.hideStrategy = "detach";
panel.motif.options.hideStrategy = "detach";
In the function component, state is the local state the component sets up with reactive.
options is the component's own settings object, and the keys the framework recognizes are read: hideStrategy and disableDisposal are copied into motif.options, and isSvg decides the element's namespace (see Root element); other keys are ignored. Do not use this name as a data prop. An options written on a function component tag is applied to the returned root.
Waiting — isWait / x-wait
The x-wait directive makes adding a component to the DOM depend on a condition; its programmatic counterpart is the isWait property (isWait is not a JSX prop). While the condition is true the component is held (hidden/not added), and when it turns false it is added. This is the primary method for conditional rendering — see Conditional Rendering and Lists. In the example, the component tag is not built for as long as the x-wait condition is true:
A child chunked with x-chunk also waits with isWait until its turn comes and leaves its marker; when its turn comes, the framework lifts the wait. If you write isWait = false earlier yourself, the child is built right away and leaves the chunk queue — see Large lists.
<TitleSync x-wait={() => this.state.loading} />
<TitleSync x-wait={() => state.loading} />
const title = motifComponent(TitleSync, { preconfig: (title: ComponentBase) => title.bindings.wait(() => this.state.loading) });
const title = motifComponent(TitleSync, { preconfig: (title: ComponentBase) => title.bindings.wait(() => state.loading) });
<TitleSync x-wait={() => this.state.loading} />
<TitleSync x-wait={() => state.loading} />
const title = motifComponent(TitleSync, { preconfig: (title) => title.bindings.wait(() => this.state.loading) });
const title = motifComponent(TitleSync, { preconfig: (title) => title.bindings.wait(() => state.loading) });
In code:
this.isWait = true; // hold
this.isWait = false; // continue (added to the DOM if needed)
panel.isWait = true; // hold
panel.isWait = false; // continue (added to the DOM if needed)
this.isWait = true; // hold
this.isWait = false; // continue (added to the DOM if needed)
panel.isWait = true; // hold
panel.isWait = false; // continue (added to the DOM if needed)
Two points that matter for the lifecycle:
- If the condition is
trueat the start,build()does not run until the condition turnsfalse.onConfig/onConfiguredrun, but theonBuilding/onBuilt/onMountedhooks do not fire until then, and the component's subtree is not built and does not enter the DOM. Only a<!--h-->marker sits in its place; when the condition turnsfalse, the component is built and takes the marker's place. - Going back to the waiting state does not dispose.
onDisposing/onDisposeddo not run and the internal state is kept; the component is hidden and getsonDeactivated. If you really want disposal, use{condition && <X/>}(bindings.when(conditionFn, buildFn)without JSX) or calldispose().
Disposal (dispose)
A component that is no longer needed is disposed. This removes its event listeners, deactivates its bindings, disposes its children recursively and takes it out of the DOM:
await this.dispose(); // default: { deep: true } + leave animation
await this.dispose({ skipLeaveTransition: true }); // without animation
await this.dispose({ deep: false }); // skip reference cleanup in the children
await this.disposeAsync(); // disposing the children: the leave transition is not played, running animations are stopped
await panel.dispose(); // default: { deep: true } + leave animation
await panel.dispose({ skipLeaveTransition: true }); // without animation
await panel.dispose({ deep: false }); // skip reference cleanup in the children
await panel.disposeAsync(); // disposing the children: the leave transition is not played, running animations are stopped
await this.dispose(); // default: { deep: true } + leave animation
await this.dispose({ skipLeaveTransition: true }); // without animation
await this.dispose({ deep: false }); // skip reference cleanup in the children
await this.disposeAsync(); // disposing the children: the leave transition is not played, running animations are stopped
await panel.dispose(); // default: { deep: true } + leave animation
await panel.dispose({ skipLeaveTransition: true }); // without animation
await panel.dispose({ deep: false }); // skip reference cleanup in the children
await panel.disposeAsync(); // disposing the children: the leave transition is not played, running animations are stopped
deep (true by default) also does reference cleanup in the child components (element, props, childs, the event maps); the root component is always cleaned up. disposeAsync is the path the framework uses for the children while a parent is being disposed; called from outside, the component goes away without waiting for the leave transition. While a disposal is in progress, a second dispose/disposeAsync call does not start a new one; it returns the same Promise.
controls.remove(child) or controls.clear() dispose the children concerned as well. Everything you set up with JSX or with motifComponent (listeners, bindings, children, context.on subscriptions) is cleaned up on its own; external resources such as timers, observers and sockets you register with motif.setDisposable (like the setInterval in NoteBoard). For details, see Memory Management and Disposal.
The application lifecycle — app.onLifecycle
Separate from component hooks, the application as a whole has a lifecycle too: the tab goes to the background, a mobile operating system freezes the page, the connection drops. For pausing and resuming polling, websockets and timers, and for refreshing stale data on return:
const off = app.onLifecycle(async ({ state }) => {
switch (state) {
case "hidden": poller.pause(); break; // tab/app in the background
case "visible": poller.resume(); break;
case "frozen": socket.close(); break; // the browser froze the page
case "resumed": socket.open(); break;
case "restored": notes.state.items = await sync.pull(); break; // came back from the back/forward cache (bfcache)
case "offline": offlineBanner.motif.show(); break;
case "online": offlineBanner.motif.hide(); break;
}
});
off(); // unsubscribe
const off = app.onLifecycle(async ({ state }) => {
switch (state) {
case "hidden": poller.pause(); break; // tab/app in the background
case "visible": poller.resume(); break;
case "frozen": socket.close(); break; // the browser froze the page
case "resumed": socket.open(); break;
case "restored": notes.state.items = await sync.pull(); break; // came back from the back/forward cache (bfcache)
case "offline": offlineBanner.motif.show(); break;
case "online": offlineBanner.motif.hide(); break;
}
});
off(); // unsubscribe
app.isVisible and app.isOnline give the current state. The browser listeners are set up only on the first subscription (if unused, they cost nothing) and are removed with app.dispose(). The subscription is not tied to a component's lifetime; if you open it from inside a component, register off with the component's motif.setDisposable(off) (this.motif in a class and in Options, the motif of the root component in a function component).
Next step
To learn about the styling and animation system, go on to Styling and Animation.