Core concepts
Events
DOM events and modifiers, motif.on/off/trigger component events, lifecycle events, the application event bus and service events with Emitter; with and without JSX.
MotifJS has three layers of events:
- DOM events — attached to an element with props such as
onclickandoninput, and without JSX withmotif.on. - Component events — communication between components with
component.motif.on/trigger/off. - Application events — application-wide broadcasts with
context.on/fire.
The examples are slices of the notes application in the starter template: the notes list (NotesLayout) and the editor (NotePage). Note and NotesService are the ones in the src/services/notes.ts file on the Components page.
this.state is the component's own state; this.remove and this.close are methods of the component.
state is the local state the component sets up with reactive; remove and close are local functions inside the component function.
DOM events
In JSX, on... props are attached to DOM events, and event names are written in lower case. Without JSX the same binding is a motif.on(name, handler) call:
<button type="button" onclick={() => notes.togglePin(note)}>Pin</button>
<input type="search" oninput={(e) => (this.state.query = (e.target as HTMLInputElement).value)} />
<form onsubmit={(sender, e) => { e.preventDefault(); notes.create(); }}>…</form>
<a href="/archive" onclick:prevent={(sender, _e) => sender.context.navigate("/archive")}>Archive</a>
<button type="button" onclick={() => notes.togglePin(note)}>Pin</button>
<input type="search" oninput={(e) => (state.query = (e.target as HTMLInputElement).value)} />
<form onsubmit={(sender, e) => { e.preventDefault(); notes.create(); }}>…</form>
<a href="/archive" onclick:prevent={(sender, _e) => sender.context.navigate("/archive")}>Archive</a>
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 search = motifComponent("input", (field: ComponentBase) => {
field.attr.add({ type: "search" });
field.motif.on("input", (_sender: ComponentBase, e: Event) => (this.state.query = (e.target as HTMLInputElement).value));
});
const form = motifComponent("form", (form: ComponentBase) => {
form.motif.on("submit", (_sender: ComponentBase, e: Event) => { e.preventDefault(); notes.create(); });
});
const archive = motifComponent("a", (link: ComponentBase) => {
link.attr.add({ href: "/archive" });
link.motif.on("click:prevent", (sender: ComponentBase, _e: Event) => sender.context.navigate("/archive"));
link.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
});
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 search = motifComponent("input", (field: ComponentBase) => {
field.attr.add({ type: "search" });
field.motif.on("input", (_sender: ComponentBase, e: Event) => (state.query = (e.target as HTMLInputElement).value));
});
const form = motifComponent("form", (form: ComponentBase) => {
form.motif.on("submit", (_sender: ComponentBase, e: Event) => { e.preventDefault(); notes.create(); });
});
const archive = motifComponent("a", (link: ComponentBase) => {
link.attr.add({ href: "/archive" });
link.motif.on("click:prevent", (sender: ComponentBase, _e: Event) => sender.context.navigate("/archive"));
link.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
});
<button type="button" onclick={() => notes.togglePin(note)}>Pin</button>
<input type="search" oninput={(e) => (this.state.query = e.target.value)} />
<form onsubmit={(sender, e) => { e.preventDefault(); notes.create(); }}>…</form>
<a href="/archive" onclick:prevent={(sender, _e) => sender.context.navigate("/archive")}>Archive</a>
<button type="button" onclick={() => notes.togglePin(note)}>Pin</button>
<input type="search" oninput={(e) => (state.query = e.target.value)} />
<form onsubmit={(sender, e) => { e.preventDefault(); notes.create(); }}>…</form>
<a href="/archive" onclick:prevent={(sender, _e) => sender.context.navigate("/archive")}>Archive</a>
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 search = motifComponent("input", (field) => {
field.attr.add({ type: "search" });
field.motif.on("input", (_sender, e) => (this.state.query = e.target.value));
});
const form = motifComponent("form", (form) => {
form.motif.on("submit", (_sender, e) => { e.preventDefault(); notes.create(); });
});
const archive = motifComponent("a", (link) => {
link.attr.add({ href: "/archive" });
link.motif.on("click:prevent", (sender, _e) => sender.context.navigate("/archive"));
link.controls.add(motifComponent("text", (node) => node.setText("Archive")));
});
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 search = motifComponent("input", (field) => {
field.attr.add({ type: "search" });
field.motif.on("input", (_sender, e) => (state.query = e.target.value));
});
const form = motifComponent("form", (form) => {
form.motif.on("submit", (_sender, e) => { e.preventDefault(); notes.create(); });
});
const archive = motifComponent("a", (link) => {
link.attr.add({ href: "/archive" });
link.motif.on("click:prevent", (sender, _e) => sender.context.navigate("/archive"));
link.controls.add(motifComponent("text", (node) => node.setText("Archive")));
});
On a plain tag every DOM event is attached this way (onpointerdown, onmouseenter and onanimationend included); a camelCase spelling such as onClick attaches the same event.
On a component tag, a prop carrying a DOM event name (onchange/onChange, onCopy, onResize, onSelect, onReset, onContextMenu …) is attached to the component's element as a DOM listener and does not reach the component's props; for the camelCase spelling the compiler prints an MJX002 warning. For a callback prop, pick a name that does not clash with a DOM event (onValueChange, onConfirm). In a component without an element (a fragment) there is no element for such a listener to be attached to (see Root element).
Handler signatures
A handler is written in one of two forms, and MotifJS calls the right one based on the number of parameters. The rule is the same for motif.on: a one-parameter handler receives the event, not the sender:
<input type="text" onkeydown={(e) => e.key === "Enter" && notes.create()} /> {/* event only */}
<li onclick={(sender, e) => sender.class.add("selected")}>…</li> {/* sender + event */}
const field = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "text" });
input.motif.on("keydown", (_sender: ComponentBase, e: KeyboardEvent) => e.key === "Enter" && notes.create());
});
const row = motifComponent("li", (li: ComponentBase) => {
li.motif.on("click", (sender: ComponentBase, _e: Event) => sender.class.add("selected"));
});
<input type="text" onkeydown={(e) => e.key === "Enter" && notes.create()} /> {/* event only */}
<li onclick={(sender, e) => sender.class.add("selected")}>…</li> {/* sender + event */}
const field = motifComponent("input", (input) => {
input.attr.add({ type: "text" });
input.motif.on("keydown", (_sender, e) => e.key === "Enter" && notes.create());
});
const row = motifComponent("li", (li) => {
li.motif.on("click", (sender, _e) => sender.class.add("selected"));
});
sender— theComponentBaseinstance that triggered the event; you can reach members such assender.element,sender.contextandsender.motif.- If the handler returns
{ cancel: true }, MotifJS callspreventDefault()andstopPropagation(). Other return values,falseincluded, are ignored, which is why the short body ofonkeydownabove does not cancel other keys. These methods are called only if they exist on the event object; when a plain data object is passed withmotif.trigger("name", { … }), neither is called and no error occurs. The:prevent/:stopmodifiers follow the same rule. - The number of parameters is the function's
length: if the declared parameter count is ≤ 1,fn(event)is called, and if it is 2 or more,fn(sender, event). A parameter with a default value or a rest parameter, and anything after it, is not counted; so(...a) => …counts as 0 and receives onlyevent, and(s = null, e) => …counts as 0 too. - In TypeScript both forms, on a plain tag and on a component tag, are typed from the element's event: with
onkeydown={(e) => e.key},eis aKeyboardEvent, and in the(s, e) => …formegets the same type. Since the meaning of the first parameter depends on the number of parameters, the event and the component are typed together (KeyboardEvent & ComponentBase); use the event's members inside(e) => …and the component's members inside(s, e) => …. The type ofmotif.onis the(sender, e)signature; if you are going to use the event, write two parameters.
Handler errors
If a handler throws, or the promise returned by an async handler is rejected, the error is reported to errorHandler as a MotifError with the code MJX123 (The '<event>' event handler threw.), with the original error in the cause field. The report is written to the console in production mode too (app.useLogging(false) turns it off) and reaches the errorHandler.addListener(fn) listeners. The other listeners of the same event keep running.
The on:name / on-name / on_name spelling
This spelling attaches the handler as it is to the tag's component: <X on:saved={fn}/> means sender.motif.on("saved", fn). The event name is everything left after the prefix (on:, on-, on_) is dropped: on-my-event → my-event, on_my_event → my_event. It is used both for DOM events and for custom events triggered with motif.trigger, and the parameter count rule above applies just the same:
<DeleteConfirm note={note} on:confirm={(target: Note) => this.remove(target)} /> {/* one parameter: the data given to trigger */}
<button type="button" on-click={(sender, _e) => sender.motif.hide()}>Close</button> {/* two parameters: sender + event */}
<button type="button" on_click={this.close}>Close</button> {/* the method reference is passed as is */}
<DeleteConfirm note={note} on:confirm={(target: Note) => remove(target)} /> {/* one parameter: the data given to trigger */}
<button type="button" on-click={(sender, _e) => sender.motif.hide()}>Close</button> {/* two parameters: sender + event */}
<button type="button" on_click={close}>Close</button> {/* the function reference is passed as is */}
const confirm = motifComponent(DeleteConfirm, { note, initializeComponent: (confirm: ComponentBase) => confirm.motif.on("confirm", (_confirm: ComponentBase, target: Note) => this.remove(target)) });
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 closeByMethod = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", this.close);
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Close")));
});
const confirm = motifComponent(DeleteConfirm, { note, initializeComponent: (confirm: ComponentBase) => confirm.motif.on("confirm", (_confirm: ComponentBase, target: Note) => remove(target)) });
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 closeByMethod = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", close);
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Close")));
});
<DeleteConfirm note={note} on:confirm={(target) => this.remove(target)} /> {/* one parameter: the data given to trigger */}
<button type="button" on-click={(sender, _e) => sender.motif.hide()}>Close</button> {/* two parameters: sender + event */}
<button type="button" on_click={this.close}>Close</button> {/* the method reference is passed as is */}
<DeleteConfirm note={note} on:confirm={(target) => remove(target)} /> {/* one parameter: the data given to trigger */}
<button type="button" on-click={(sender, _e) => sender.motif.hide()}>Close</button> {/* two parameters: sender + event */}
<button type="button" on_click={close}>Close</button> {/* the function reference is passed as is */}
const confirm = motifComponent(DeleteConfirm, { note, initializeComponent: (confirm) => confirm.motif.on("confirm", (_confirm, target) => this.remove(target)) });
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 closeByMethod = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", this.close);
button.controls.add(motifComponent("text", (node) => node.setText("Close")));
});
const confirm = motifComponent(DeleteConfirm, { note, initializeComponent: (confirm) => confirm.motif.on("confirm", (_confirm, target) => remove(target)) });
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 closeByMethod = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", close);
button.controls.add(motifComponent("text", (node) => node.setText("Close")));
});
When the "Delete" button is pressed, the delete confirmation DeleteConfirm calls motif.trigger("confirm", note) on its own component (this.motif.trigger in a class and in Options, the motif.trigger of the root it returns in a function component), and the first handler receives the note. Without JSX the listener is set up in the initializeComponent key, the same for class, function and Options components. Since the event name is free, the parameters of on:/on-/on_ props are typed any; you give the data its own type in the parameter ((target: Note) => …). The type of motif.on is (sender, e), so without JSX the data is the second parameter.
Event modifiers
Modifiers added to the event name with a colon adjust the behaviour. They are defined in the component.motif.on API and in the type system:
| Modifier | Effect |
|---|---|
:once |
Runs once, then removes itself. |
:passive |
passive: true for addEventListener. |
:capture |
Listens in the capture phase. |
:prevent |
preventDefault() after the handler. |
:stop |
stopPropagation() after the handler. |
:self |
Runs only if the event target is the element itself. |
:trusted |
Runs only for isTrusted events. |
In JSX an attribute name can take only one :, so on a tag you write one modifier per attribute; with motif.on they chain. A prop with a modifier gets the same type as the one without; inside onkeydown:once={(e) => e.key}, e is a KeyboardEvent:
<form onsubmit:prevent={() => notes.create()}>…</form>
<button type="button" onclick:once={() => notes.create()}>Create the first note</button>
<div class="list" onscroll:passive={() => rememberScroll()}>…</div>
const form = motifComponent("form", (form: ComponentBase) => {
form.motif.on("submit:prevent", () => notes.create());
});
const first = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click:once", () => notes.create());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Create the first note")));
});
const list = motifComponent("div", (list: ComponentBase) => {
list.class.add("list");
list.motif.on("scroll:passive", () => rememberScroll());
});
<form onsubmit:prevent={() => notes.create()}>…</form>
<button type="button" onclick:once={() => notes.create()}>Create the first note</button>
<div class="list" onscroll:passive={() => rememberScroll()}>…</div>
const form = motifComponent("form", (form) => {
form.motif.on("submit:prevent", () => notes.create());
});
const first = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click:once", () => notes.create());
button.controls.add(motifComponent("text", (node) => node.setText("Create the first note")));
});
const list = motifComponent("div", (list) => {
list.class.add("list");
list.motif.on("scroll:passive", () => rememberScroll());
});
The component event API: motif.on / motif.off / motif.trigger
Programmatic event handling on every component lives in the motif namespace. In the example, when the row is clicked the component triggers its own saved event; that event's handler is registered internally only:
const onSaved = (sender: ComponentBase, saved: Note) => notes.touch(saved);
this.motif.on("click", () => this.motif.trigger("saved", note));
this.motif.on("saved", onSaved, false);
this.motif.off("saved", onSaved);
const onSaved = (sender: ComponentBase, saved: Note) => notes.touch(saved);
row.motif.on("click", () => row.motif.trigger("saved", note));
row.motif.on("saved", onSaved, false);
row.motif.off("saved", onSaved);
const onSaved = (sender, saved) => notes.touch(saved);
this.motif.on("click", () => this.motif.trigger("saved", note));
this.motif.on("saved", onSaved, false);
this.motif.off("saved", onSaved);
const onSaved = (sender, saved) => notes.touch(saved);
row.motif.on("click", () => row.motif.trigger("saved", note));
row.motif.on("saved", onSaved, false);
row.motif.off("saved", onSaved);
In a function component these calls are made on the component itself (row 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.
- Listeners added with
motif.onare cleaned up automatically when the component is disposed. - The third argument of
motif.onisdomEvent(trueby default). If you passfalse, noaddEventListeneris done on the DOM; it is kept only as an internal (custom) event and is triggered withmotif.trigger. - On a component without an element (a fragment), a DOM listener listens on the component's content: each of its top-level element children, nested fragments included. A child added later is covered and a child that leaves is released;
:selfmeans the target is one of these elements;:onceandmotif.offact on all of them at once. motif.isEventFrom(e)tells whether a DOM event started on this component's element or on an element inside it. A component without an element (a fragment) answers for its content elements. The answer is the same when asked after the event has finished; data given withmotif.triggeris not a DOM event, so it returnsfalse.motif.trigger("name", data)calls only the handlers registered under exactly that name (without modifiers, case-insensitive); a handler registered with"click:once"is not called bytrigger("click"). Handlers receivedataas the event object; no event is sent to the DOM. A:oncehandler is used up by whichever path reaches it first:trigger("click:once", data)runs it once and removes it, just like a DOM click; after that,motif.options.hasEvent("click:once")returnsfalse.- For DOM events,
motif.off(name, fn)removes one registration of the same handler; if the same handler was added several times, call it once for each. The event name is given with the spelling used at registration (modifiers included). motif.addHandler(name, fn)is the same asmotif.on(name, fn)(no return value).motif.toggle()hides the component if it is visible and shows it otherwise.
x:mounted — the moment of attaching to the DOM
A hook that runs when the element is actually added to the DOM; for work such as giving focus to the title field:
this.motif.on("x:mounted", () => this.element.focus());
title.motif.on("x:mounted", () => title.element.focus());
this.motif.on("x:mounted", () => this.element.focus());
title.motif.on("x:mounted", () => title.element.focus());
In the function component, title is the component of the title field: obtained with ref in JSX, and the variable built with motifComponent without JSX.
Each subscription runs once, in the task after the element is attached to document; if the element is already in document, in the task after the subscription. When the component is detached and placed again (moving, motif.hide/motif.show, keepAlive), x:mounted does not fire again; use x:activated for that moment (Lifecycle). motif.off("x:mounted", handler) cancels a subscription that has not run yet. An error thrown by the handler is reported as MJX122.
Lifecycle events
You can also listen to component lifecycle events with motif.on. The x: prefix attaches these events not to the DOM but to the component's own emitter; the handler is called with the (sender, e) signature (e: { cancel }), and a one-parameter handler receives only e:
this.motif.on("x:built", (sender) => sender.class.add("ready"));
this.motif.on("x:disposed", () => clearInterval(timer));
row.motif.on("x:built", (sender) => sender.class.add("ready"));
row.motif.on("x:disposed", () => clearInterval(timer));
this.motif.on("x:built", (sender) => sender.class.add("ready"));
this.motif.on("x:disposed", () => clearInterval(timer));
row.motif.on("x:built", (sender) => sender.class.add("ready"));
row.motif.on("x:disposed", () => clearInterval(timer));
Recognized names: x:initializing, x:initialized, x:config, x:configured, x:building, x:built, x:mounted, x:visibilityChanged, x:activated, x:deactivated, x:disposing, x:disposed. The subscription is cleaned up together with the component, and it can also be removed by hand with motif.off. If the same handler subscribed several times, motif.off removes all of them. x:initializing and x:initialized end in the component's constructor; if you subscribe to a built component later, the handler runs once at the moment of subscribing.
If one of these listeners throws, the error is reported to errorHandler with the code MJX122 (e.g. The component x:built hook threw.), as if the hook had been written as a class method or a prop; the component keeps being built.
A note on timing: on plain
new Component("div", …)instancesx:configfires in the constructor, so subscribing after the constructor returns is too late. In subclasses (class X extends Component)onConfigruns at the start ofbuild(), so subscribing after the constructor is enough. Details: Lifecycle.
Application events — context.on / context.fire
To communicate between components (without a parent-child relationship), use the application event bus. A component's context is the running Application (this.context in a class and in Options, the context of the root component in a function component); when the listening component is disposed, the subscription goes away on its own. ArchiveCounter counts archived notes: in the class it is this.context.on inside onConfig, in the function an onConfig prop written on the root, and in Options onConfigured; without JSX all of them are the same context.on call:
import { Component, reactive } from "@motifx/core";
export class ArchiveCounter extends Component {
state = reactive({ count: 0 });
override onConfig() {
this.context.on("noteArchived", () => this.state.count++);
}
override view() {
return <span class="archived">{() => `${this.state.count} archived`}</span>;
}
}
import { reactive } from "@motifx/core";
export function ArchiveCounter() {
const state = reactive({ count: 0 });
return (
<span class="archived" onConfig={(counter) => counter.context.on("noteArchived", () => state.count++)}>
{() => `${state.count} archived`}
</span>
);
}
import { motifOptions, reactive } from "@motifx/core";
export const ArchiveCounter = () => motifOptions({
el: "span",
data: reactive({ count: 0 }),
onConfigured() {
this.class.add("archived");
this.context.on("noteArchived", () => this.data.count++);
},
view() {
return <>{() => `${this.data.count} archived`}</>;
},
});
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
export class ArchiveCounter extends Component {
state = reactive({ count: 0 });
override onConfig() {
this.context.on("noteArchived", () => this.state.count++);
}
override view() {
return motifComponent("span", (span: ComponentBase) => {
span.class.add("archived");
span.bindings.text(() => `${this.state.count} archived`);
});
}
}
import { ComponentBase, motifComponent, reactive } from "@motifx/core";
export function ArchiveCounter() {
const state = reactive({ count: 0 });
return motifComponent("span", {
onConfig: (counter: ComponentBase) => counter.context.on("noteArchived", () => state.count++),
initializeComponent: (span: ComponentBase) => {
span.class.add("archived");
span.bindings.text(() => `${state.count} archived`);
},
});
}
import { ComponentBase, motifComponent, motifOptions, reactive } from "@motifx/core";
export const ArchiveCounter = () => motifOptions({
el: "span",
data: reactive({ count: 0 }),
onConfigured() {
this.class.add("archived");
this.context.on("noteArchived", () => this.data.count++);
},
view() {
return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => `${this.data.count} archived`));
},
});
import { Component, reactive } from "@motifx/core";
export class ArchiveCounter extends Component {
state = reactive({ count: 0 });
onConfig() {
this.context.on("noteArchived", () => this.state.count++);
}
view() {
return <span class="archived">{() => `${this.state.count} archived`}</span>;
}
}
import { reactive } from "@motifx/core";
export function ArchiveCounter() {
const state = reactive({ count: 0 });
return (
<span class="archived" onConfig={(counter) => counter.context.on("noteArchived", () => state.count++)}>
{() => `${state.count} archived`}
</span>
);
}
import { motifOptions, reactive } from "@motifx/core";
export const ArchiveCounter = () => motifOptions({
el: "span",
data: reactive({ count: 0 }),
onConfigured() {
this.class.add("archived");
this.context.on("noteArchived", () => this.data.count++);
},
view() {
return <>{() => `${this.data.count} archived`}</>;
},
});
import { Component, motifComponent, reactive } from "@motifx/core";
export class ArchiveCounter extends Component {
state = reactive({ count: 0 });
onConfig() {
this.context.on("noteArchived", () => this.state.count++);
}
view() {
return motifComponent("span", (span) => {
span.class.add("archived");
span.bindings.text(() => `${this.state.count} archived`);
});
}
}
import { motifComponent, reactive } from "@motifx/core";
export function ArchiveCounter() {
const state = reactive({ count: 0 });
return motifComponent("span", {
onConfig: (counter) => counter.context.on("noteArchived", () => state.count++),
initializeComponent: (span) => {
span.class.add("archived");
span.bindings.text(() => `${state.count} archived`);
},
});
}
import { motifComponent, motifOptions, reactive } from "@motifx/core";
export const ArchiveCounter = () => motifOptions({
el: "span",
data: reactive({ count: 0 }),
onConfigured() {
this.class.add("archived");
this.context.on("noteArchived", () => this.data.count++);
},
view() {
return motifComponent("text", (node) => node.bindings.text(() => `${this.data.count} archived`));
},
});
The publisher broadcasts through the context of the component sending the event; the second argument of fire goes to the listeners as data:
<button type="button" onclick={(sender, e) => { notes.archive(note); sender.context.fire("noteArchived", note); }}>Archive</button>
const archive = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", (sender: ComponentBase, _e: Event) => { notes.archive(note); sender.context.fire("noteArchived", note); });
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Archive")));
});
<button type="button" onclick={(sender, e) => { notes.archive(note); sender.context.fire("noteArchived", note); }}>Archive</button>
const archive = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", (sender, _e) => { notes.archive(note); sender.context.fire("noteArchived", note); });
button.controls.add(motifComponent("text", (node) => node.setText("Archive")));
});
The Application event API:
| Method | Description |
|---|---|
app.on(event, handler) |
Listens; returns a function for unsubscribing. The handler's parameter is the data given to fire ((note?: Note) => …). |
app.fire(event, args?) |
Triggers the event. |
app.off(event, handler) |
Removes the listener. |
app.onRouterChanged(handler) |
A hook that runs on every navigation (startup and 404 included); returns a function for unsubscribing. |
app.onLifecycle(handler) |
Tab/application visibility and connectivity changes; see Application lifecycle. |
An event name can be a string or a Symbol; fire calls the listeners synchronously, in the order they were added.
An error thrown by an application event listener is reported with the code MJX123 (The '<event>' event handler threw.), and the event's other listeners run. The same goes for onRouterChanged handlers (The 'motifjs-router-navigated' event handler threw.); the navigation still completes.
A component's context is a view of the application tied to the component: the on and onRouterChanged subscriptions opened through it are removed on their own when the component is disposed (onLifecycle is not covered by this). Subscriptions opened through Application.main, an app instance you hold, or useApplication().application are not tied to any component; call the returned function yourself, or register it on a component with motif.setDisposable (Memory Management and Disposal):
const unsubscribe = app.on<Note>("noteArchived", (note) => {
if (note) document.title = `${note.title} archived`;
});
unsubscribe();
const unsubscribe = app.on("noteArchived", (note) => {
if (note) document.title = `${note.title} archived`;
});
unsubscribe();
Service events — Emitter<T>
Application events work by name and are open to the whole application. For a service to broadcast its own event together with its type, use Emitter<T>; it is a small emitter that carries a single type and is independent of components. The service in the example announces the deleted note:
import { Emitter, Injectable, reactive } from "@motifx/core";
export interface Note {
id: number;
title: string;
body: string;
pinned: boolean;
archived: boolean;
createdAt: number;
updatedAt: number;
}
@Injectable({ lifetime: "singleton" })
export class NotesService {
readonly state = reactive({ items: [] as Note[] });
private readonly removed = new Emitter<Note>();
readonly onRemoved = this.removed.event;
remove(id: number): boolean {
const index = this.state.items.findIndex((note) => note.id === id);
if (index < 0) return false;
const [note] = this.state.items.splice(index, 1);
this.removed.fire(note);
return true;
}
dispose() {
this.removed.dispose();
}
}
import { Emitter, Injectable, reactive } from "@motifx/core";
@Injectable({ lifetime: "singleton" })
export class NotesService {
state = reactive({ items: [] });
removed = new Emitter();
onRemoved = this.removed.event;
remove(id) {
const index = this.state.items.findIndex((note) => note.id === id);
if (index < 0) return false;
const [note] = this.state.items.splice(index, 1);
this.removed.fire(note);
return true;
}
dispose() {
this.removed.dispose();
}
}
A component ties the subscription to its own lifetime with motif.register; when the component is disposed, the subscription goes away:
this.motif.register(this.notes.onRemoved((note) => (this.state.removed = note.title)));
list.motif.register(notes.onRemoved((note) => (state.removed = note.title)));
this.motif.register(this.notes.onRemoved((note) => (this.state.removed = note.title)));
list.motif.register(notes.onRemoved((note) => (state.removed = note.title)));
In the function component, list is the component that carries the subscription: 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.
event(listener, thisArgs?, disposables?)subscribes and returns anIDisposable; ifdisposablesis aDisposableStoreor anIDisposable[], the subscription is added there too (notes.onRemoved(fn, undefined, store)).fire(value)calls the listeners synchronously, in the order they were added. A listener added during dispatch does not receive that dispatch; a removed listener is never called again.- An error thrown by a listener does not stop the others; it goes down the unexpected error path (see Unexpected errors).
dispose()invalidates every subscription; laterevent(...)calls return an emptyIDisposable. The provider calls the service'sdispose()method; for a singleton that is the moment ofapp.dispose()(see Disposing Services).
Next step
To learn about form input and two-way binding, go on to Forms and Two-Way Binding.