Core concepts
Reactivity
reactive, createSignal, createComputed, createLazyComputed, effect, untracked, and how bindings meet the DOM; with and without JSX.
MotifJS's reactivity system is fine-grained. When a field of a model changes, only the bindings and effects that read that field run again; the component is not re-rendered.
There are several tools for holding state, and each one is designed for its own job; what decides the choice is the shape of the thing being held. A note, with its title, body, flags and the lists inside it, is a model: reactive wraps it as it is, tracks each field separately and makes nested objects and arrays reactive as well, and a changed field wakes only the binding that reads it. If the state is a single value, such as the id of the selected note or a scroll position, there is no need to wrap it in an object: createSignal holds that value, the value is written as a whole and, if you like, compared with its own equality rule. If hundreds of rows compare this id with their own id, createSelection wakes only the previously selected row and the newly selected one when the selection changes (see The selected row). Instead of keeping a value derived from other values up to date by hand, createComputed calculates it; if the calculation is expensive and the value is read rarely, createLazyComputed does not calculate until it is read. When a change has to be answered with work outside the screen, you write an effect. They all run on the same tracking mechanism, can read one another and reach the screen through the same bindings; a reactive model and a signal can sit side by side in the same component.
| Tool | What it is for |
|---|---|
reactive(model) |
A model with fields: nested objects, arrays, Map and Set. Each field is tracked separately; a component's or a service's state is usually this. useModel does the same job. |
createSignal(value) |
A single value: a number, a string, a selected id. The value is written as a whole through .value; changing an object inside it in place does not notify, so you notify with mutate(). An equality comparer can be given. |
createComputed(getter) |
A value derived from other values and read constantly. It is kept ready and recomputed on the next microtask when a dependency changes. |
createLazyComputed(getter) |
An expensive derived value that is read less often than it is written. It is not computed until it is read. |
effect(fn) |
Answering a change with a side effect: the document title, localStorage. Inside a component use bindings.watch, which stops together with the component. |
The examples are slices of the notes application in the starter template. Note and NotesService are in src/services/notes.ts on the Components page, and SettingsService is in src/services/settings.ts on the Getting Started page; notes and settings are instances of these services.
reactive(model)
Turns an object (or an array) into a reactive proxy. You read and write the fields as you would on a normal object; reads are tracked and writes trigger dependents. If a primitive value (a number, a string, a boolean) is given, it is wrapped in an object of the form { value }: reactive("") returns a { value: "" } proxy, and the value is read and written through .value.
import { reactive } from "@motifx/core";
import type { Note } from "./services/notes";
const state = reactive({ items: [] as Note[], saved: false });
state.saved = true; // triggers the bindings tied to this field
state.items.unshift(note); // array methods are tracked
state.items[0].title = "Meeting notes"; // nested objects are reactive too
state.items[state.items.length] = draft; // appending by index also triggers the index and length
state.items.length = 0; // truncating wakes the readers of the dropped indices
import { reactive } from "@motifx/core";
const state = reactive({ items: [], saved: false });
state.saved = true; // triggers the bindings tied to this field
state.items.unshift(note); // array methods are tracked
state.items[0].title = "Meeting notes"; // nested objects are reactive too
state.items[state.items.length] = draft; // appending by index also triggers the index and length
state.items.length = 0; // truncating wakes the readers of the dropped indices
NotesService keeps the notes in a state.items array like this; create() adds a new note to the front with unshift.
A write that does not change the value wakes nobody: writing the same value to a field, replacing an array element with itself or setting the length to the same number triggers nothing; the comparison uses Object.is. If a reactive object is put into another reactive field or array, its raw object is stored (state.current = state.items[0]); when read, the same reactive object comes back, and the raw data does not carry proxies.
The array's read methods (find, findIndex, some, every, filter, map, forEach, reduce, join, slice, includes, indexOf), for...of and spreading ([...state.items]) subscribe to the array through a single key rather than index by index: an index write that changes a value, a change of length, or a structural method such as push, splice or sort wakes them once. A search that stops at a match (find, findIndex, some, every, indexOf, includes) is tied only to the prefix it walked: a write beyond the index where it stopped, an insertion in between or a push at the end does not wake it; a search that completes without a match, and findLast/findLastIndex, are tied to the whole list. The fields the callback reads on the elements (note.title) subscribe to those elements as well; the elements returned by find and filter are reactive too. A direct index read (state.items[3]) tracks only that index.
The proxy is set up on top of the original object; no copy is taken. The same proxy comes back for the same object (reactive(a) === reactive(a)), and if the proxy itself is given, it is returned. clearModel(a) drops this record; after that, reactive(a) gives a new proxy (see Helpers). Plain objects, arrays, class instances ([object Object]) and Map, Set, WeakMap and WeakSet become reactive (collections). In getters and methods called through the proxy, this is the proxy, so a getter or method that touches a class's private field written with # throws a TypeError, whether the instance is wrapped directly or read from inside a reactive object. Keep the reactive state of such a class in a reactive({ … }) field inside the class; a field written with TypeScript's private keyword is an ordinary field, works through the proxy and is reactive. Component, Application and ServiceProvider instances and app.router are returned as they are; they are not wrapped even if they are put in a field of a reactive object. Objects such as Date and RegExp, and non-extensible (Object.freeze) objects, are returned as they are, and changes inside them do not trigger bindings. RegExp is not proxied: inside a reactive object, test, exec and replace work as usual. The pattern does not change in place; a new pattern is written to the field as a new RegExp (highlight.pattern = /new/), and that write triggers bindings. When a Date changes, write the field with a new reference (reminder.due = new Date(reminder.due.getTime() + 24 * 60 * 60 * 1000)).
State in a component
State is a field in a class, a closure variable in a function, and the data field in the Options API; all three are the same reactive object, and the getters in the view read it. FilterBar holds a search box and a "pinned only" option, and the number of matching notes is derived from the two fields:
import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export class FilterBar extends Component {
notes = this.getService(NotesService)!;
state = reactive({ query: "", onlyPinned: false });
matches() {
return this.notes.list("notes", this.state.query).filter((note) => !this.state.onlyPinned || note.pinned);
}
override view() {
return (
<div class="filter">
<input type="search" placeholder="Search notes" value={() => this.state.query} oninput={(e) => (this.state.query = (e.target as HTMLInputElement).value)} />
<label>
<input type="checkbox" checked={() => this.state.onlyPinned} onchange={(e) => (this.state.onlyPinned = (e.target as HTMLInputElement).checked)} />
Pinned only
</label>
<small>{() => `${this.matches().length} notes`}</small>
</div>
);
}
}
import { FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export function FilterBar() {
const notes = FromService(NotesService)!;
const state = reactive({ query: "", onlyPinned: false });
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
return (
<div class="filter">
<input type="search" placeholder="Search notes" value={() => state.query} oninput={(e) => (state.query = (e.target as HTMLInputElement).value)} />
<label>
<input type="checkbox" checked={() => state.onlyPinned} onchange={(e) => (state.onlyPinned = (e.target as HTMLInputElement).checked)} />
Pinned only
</label>
<small>{() => `${matches().length} notes`}</small>
</div>
);
}
import { FromService, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export const FilterBar = () => motifOptions({
el: "div",
data: reactive({ query: "", onlyPinned: false }),
view() {
const notes = FromService(NotesService)!;
const state = this.data;
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
this.class.add("filter");
return (
<>
<input type="search" placeholder="Search notes" value={() => state.query} oninput={(e) => (state.query = (e.target as HTMLInputElement).value)} />
<label>
<input type="checkbox" checked={() => state.onlyPinned} onchange={(e) => (state.onlyPinned = (e.target as HTMLInputElement).checked)} />
Pinned only
</label>
<small>{() => `${matches().length} notes`}</small>
</>
);
},
});
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export class FilterBar extends Component {
notes = this.getService(NotesService)!;
state = reactive({ query: "", onlyPinned: false });
matches() {
return this.notes.list("notes", this.state.query).filter((note) => !this.state.onlyPinned || note.pinned);
}
override view() {
return motifComponent("div", (bar: ComponentBase) => {
bar.class.add("filter");
const field = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => this.state.query);
input.motif.on("input", (_sender: ComponentBase, e: Event) => (this.state.query = (e.target as HTMLInputElement).value));
});
const label = motifComponent("label", (label: ComponentBase) => {
const box = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => this.state.onlyPinned);
input.motif.on("change", (_sender: ComponentBase, e: Event) => (this.state.onlyPinned = (e.target as HTMLInputElement).checked));
});
label.controls.add(box, motifComponent("text", (node: ComponentBase) => node.setText("Pinned only")));
});
const count = motifComponent("small", (small: ComponentBase) => small.bindings.text(() => `${this.matches().length} notes`));
bar.controls.add(field, label, count);
});
}
}
import { ComponentBase, FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export function FilterBar() {
const notes = FromService(NotesService)!;
const state = reactive({ query: "", onlyPinned: false });
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
return motifComponent("div", (bar: ComponentBase) => {
bar.class.add("filter");
const field = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => state.query);
input.motif.on("input", (_sender: ComponentBase, e: Event) => (state.query = (e.target as HTMLInputElement).value));
});
const label = motifComponent("label", (label: ComponentBase) => {
const box = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => state.onlyPinned);
input.motif.on("change", (_sender: ComponentBase, e: Event) => (state.onlyPinned = (e.target as HTMLInputElement).checked));
});
label.controls.add(box, motifComponent("text", (node: ComponentBase) => node.setText("Pinned only")));
});
const count = motifComponent("small", (small: ComponentBase) => small.bindings.text(() => `${matches().length} notes`));
bar.controls.add(field, label, count);
});
}
import { ComponentBase, FromService, motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export const FilterBar = () => motifOptions({
el: "div",
data: reactive({ query: "", onlyPinned: false }),
view() {
const notes = FromService(NotesService)!;
const state = this.data;
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
this.class.add("filter");
const field = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => state.query);
input.motif.on("input", (_sender: ComponentBase, e: Event) => (state.query = (e.target as HTMLInputElement).value));
});
const label = motifComponent("label", (label: ComponentBase) => {
const box = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => state.onlyPinned);
input.motif.on("change", (_sender: ComponentBase, e: Event) => (state.onlyPinned = (e.target as HTMLInputElement).checked));
});
label.controls.add(box, motifComponent("text", (node: ComponentBase) => node.setText("Pinned only")));
});
const count = motifComponent("small", (small: ComponentBase) => small.bindings.text(() => `${matches().length} notes`));
return [field, label, count];
},
});
import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export class FilterBar extends Component {
notes = this.getService(NotesService);
state = reactive({ query: "", onlyPinned: false });
matches() {
return this.notes.list("notes", this.state.query).filter((note) => !this.state.onlyPinned || note.pinned);
}
view() {
return (
<div class="filter">
<input type="search" placeholder="Search notes" value={() => this.state.query} oninput={(e) => (this.state.query = e.target.value)} />
<label>
<input type="checkbox" checked={() => this.state.onlyPinned} onchange={(e) => (this.state.onlyPinned = e.target.checked)} />
Pinned only
</label>
<small>{() => `${this.matches().length} notes`}</small>
</div>
);
}
}
import { FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export function FilterBar() {
const notes = FromService(NotesService);
const state = reactive({ query: "", onlyPinned: false });
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
return (
<div class="filter">
<input type="search" placeholder="Search notes" value={() => state.query} oninput={(e) => (state.query = e.target.value)} />
<label>
<input type="checkbox" checked={() => state.onlyPinned} onchange={(e) => (state.onlyPinned = e.target.checked)} />
Pinned only
</label>
<small>{() => `${matches().length} notes`}</small>
</div>
);
}
import { FromService, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export const FilterBar = () => motifOptions({
el: "div",
data: reactive({ query: "", onlyPinned: false }),
view() {
const notes = FromService(NotesService);
const state = this.data;
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
this.class.add("filter");
return (
<>
<input type="search" placeholder="Search notes" value={() => state.query} oninput={(e) => (state.query = e.target.value)} />
<label>
<input type="checkbox" checked={() => state.onlyPinned} onchange={(e) => (state.onlyPinned = e.target.checked)} />
Pinned only
</label>
<small>{() => `${matches().length} notes`}</small>
</>
);
},
});
import { Component, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export class FilterBar extends Component {
notes = this.getService(NotesService);
state = reactive({ query: "", onlyPinned: false });
matches() {
return this.notes.list("notes", this.state.query).filter((note) => !this.state.onlyPinned || note.pinned);
}
view() {
return motifComponent("div", (bar) => {
bar.class.add("filter");
const field = motifComponent("input", (input) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => this.state.query);
input.motif.on("input", (_sender, e) => (this.state.query = e.target.value));
});
const label = motifComponent("label", (label) => {
const box = motifComponent("input", (input) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => this.state.onlyPinned);
input.motif.on("change", (_sender, e) => (this.state.onlyPinned = e.target.checked));
});
label.controls.add(box, motifComponent("text", (node) => node.setText("Pinned only")));
});
const count = motifComponent("small", (small) => small.bindings.text(() => `${this.matches().length} notes`));
bar.controls.add(field, label, count);
});
}
}
import { FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export function FilterBar() {
const notes = FromService(NotesService);
const state = reactive({ query: "", onlyPinned: false });
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
return motifComponent("div", (bar) => {
bar.class.add("filter");
const field = motifComponent("input", (input) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => state.query);
input.motif.on("input", (_sender, e) => (state.query = e.target.value));
});
const label = motifComponent("label", (label) => {
const box = motifComponent("input", (input) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => state.onlyPinned);
input.motif.on("change", (_sender, e) => (state.onlyPinned = e.target.checked));
});
label.controls.add(box, motifComponent("text", (node) => node.setText("Pinned only")));
});
const count = motifComponent("small", (small) => small.bindings.text(() => `${matches().length} notes`));
bar.controls.add(field, label, count);
});
}
import { FromService, motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export const FilterBar = () => motifOptions({
el: "div",
data: reactive({ query: "", onlyPinned: false }),
view() {
const notes = FromService(NotesService);
const state = this.data;
const matches = () => notes.list("notes", state.query).filter((note) => !state.onlyPinned || note.pinned);
this.class.add("filter");
const field = motifComponent("input", (input) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => state.query);
input.motif.on("input", (_sender, e) => (state.query = e.target.value));
});
const label = motifComponent("label", (label) => {
const box = motifComponent("input", (input) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => state.onlyPinned);
input.motif.on("change", (_sender, e) => (state.onlyPinned = e.target.checked));
});
label.controls.add(box, motifComponent("text", (node) => node.setText("Pinned only")));
});
const count = motifComponent("small", (small) => small.bindings.text(() => `${matches().length} notes`));
return [field, label, count];
},
});
matches() is an ordinary method; when the getter inside <small> calls it, it reads state.query, state.onlyPinned and the archived, title and pinned fields of the notes in the service. Whichever of these fields changes, only that text is recomputed.
Changing state only through methods
When you want to know where a piece of state changes from, validate a change or keep a record of it, do not hand the reactive object out. The state stays inside a module or a service, and the outside world gets getters for reading and methods for changing it. Since the getter reads the reactive object, bindings and effects track it. Since every change goes through a method, the rule, the validation or the record lives in one place. The notes selected for a bulk action in the notes list are state of this kind.
Without DI, in a module:
import { reactive } from "@motifx/core";
const state = reactive({ ids: [] as number[] });
export const selection = {
get ids(): readonly number[] {
return state.ids;
},
get count() {
return state.ids.length;
},
has(id: number) {
return state.ids.includes(id);
},
toggle(id: number) {
const index = state.ids.indexOf(id);
if (index < 0) state.ids.push(id);
else state.ids.splice(index, 1);
},
clear() {
state.ids.length = 0;
},
};
import { reactive } from "@motifx/core";
const state = reactive({ ids: [] });
export const selection = {
get ids() {
return state.ids;
},
get count() {
return state.ids.length;
},
has(id) {
return state.ids.includes(id);
},
toggle(id) {
const index = state.ids.indexOf(id);
if (index < 0) state.ids.push(id);
else state.ids.splice(index, 1);
},
clear() {
state.ids.length = 0;
},
};
state never leaves the module; only selection's methods write to it. The component imports selection and uses it:
<p>{() => `${selection.count} notes selected`}</p>
<input type="checkbox" checked={() => selection.has(note.id)} onchange={() => selection.toggle(note.id)} />
<button type="button" disabled={() => selection.count === 0} onclick={() => selection.clear()}>Clear selection</button>
const count = motifComponent("p", (p: ComponentBase) => p.bindings.text(() => `${selection.count} notes selected`));
const box = motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => selection.has(note.id));
input.motif.on("change", () => selection.toggle(note.id));
});
const clear = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", disabled: () => selection.count === 0 });
button.motif.on("click", () => selection.clear());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Clear selection")));
});
<p>{() => `${selection.count} notes selected`}</p>
<input type="checkbox" checked={() => selection.has(note.id)} onchange={() => selection.toggle(note.id)} />
<button type="button" disabled={() => selection.count === 0} onclick={() => selection.clear()}>Clear selection</button>
const count = motifComponent("p", (p) => p.bindings.text(() => `${selection.count} notes selected`));
const box = motifComponent("input", (input) => {
input.attr.add({ type: "checkbox" });
input.bindings.add("checked", () => selection.has(note.id));
input.motif.on("change", () => selection.toggle(note.id));
});
const clear = motifComponent("button", (button) => {
button.attr.add({ type: "button", disabled: () => selection.count === 0 });
button.motif.on("click", () => selection.clear());
button.controls.add(motifComponent("text", (node) => node.setText("Clear selection")));
});
With DI, in a service:
import { reactive } from "@motifx/core";
export class SelectionService {
private readonly state = reactive({ ids: [] as number[] });
get ids(): readonly number[] {
return this.state.ids;
}
get count() {
return this.state.ids.length;
}
has(id: number) {
return this.state.ids.includes(id);
}
toggle(id: number) {
const index = this.state.ids.indexOf(id);
if (index < 0) this.state.ids.push(id);
else this.state.ids.splice(index, 1);
}
clear() {
this.state.ids.length = 0;
}
}
import { reactive } from "@motifx/core";
export class SelectionService {
state = reactive({ ids: [] });
get ids() {
return this.state.ids;
}
get count() {
return this.state.ids.length;
}
has(id) {
return this.state.ids.includes(id);
}
toggle(id) {
const index = this.state.ids.indexOf(id);
if (index < 0) this.state.ids.push(id);
else this.state.ids.splice(index, 1);
}
clear() {
this.state.ids.length = 0;
}
}
builder.services.addSingleton(SelectionService, { useClass: SelectionService });
builder.services.addSingleton(SelectionService, { useClass: SelectionService });
The component gets the service with this.getService(SelectionService)! in a class, and with FromService(SelectionService)! in function and Options components; the usage is the same as above. The service route also brings DI's scope: if the selection should reset when the page changes, register the service with addScoped, and every navigation gets its own instance (see scoped).
On both routes, selection.count = 3 and selection.ids.push(…) do not compile in TypeScript; the getter's return type is readonly number[], so the array cannot be changed from outside either. The service's state field is private, so access from outside does not compile. Assigning to a field that has a getter also gives a TypeError at run time in an ES module. TypeScript's readonly and private protections are compile-time ones; the state in the module, on the other hand, is invisible from outside at run time as well. Even when a method writes several fields, the bindings that track it run once.
To close it off at run time too, hand out a view made with asReadonly. The view reads the same data and tracks its changes; writing to it, deleting from it or calling a changing method such as push throws an MJX212 error:
import { reactive, asReadonly } from "@motifx/core";
const state = reactive({ ids: [] as number[] });
const view = asReadonly(state);
export const selection = {
get ids() {
return view.ids;
},
toggle(id: number) {
const index = state.ids.indexOf(id);
if (index < 0) state.ids.push(id);
else state.ids.splice(index, 1);
},
};
import { reactive, asReadonly } from "@motifx/core";
const state = reactive({ ids: [] });
const view = asReadonly(state);
export const selection = {
get ids() {
return view.ids;
},
toggle(id) {
const index = state.ids.indexOf(id);
if (index < 0) state.ids.push(id);
else state.ids.splice(index, 1);
},
};
selection.ids.push(4) is now rejected at run time as well; the getter's return type is read-only by itself, and there is no need to write readonly number[] separately.
asReadonly(obj) — a read-only view
Gives a live, read-only view of a reactive object, array, Map or Set. It is not a copy: it looks at the same data, every change made through the original object shows up in the view as well, and the bindings that read the view wake up.
import { reactive, asReadonly, isReadonly } from "@motifx/core";
import type { Note } from "../services/notes";
const state = reactive({ items: [] as Note[], saving: false });
export const notes = asReadonly(state);
notes.saving; // read and tracked
state.saving = true; // bindings that read notes.saving wake up
isReadonly(notes); // true; notes.items and its elements are read-only too
// notes.saving = false; → MJX212 (and TypeScript does not compile it)
// notes.items.push(draft); → MJX212
import { reactive, asReadonly, isReadonly } from "@motifx/core";
const state = reactive({ items: [], saving: false });
export const notes = asReadonly(state);
notes.saving; // read and tracked
state.saving = true; // bindings that read notes.saving wake up
isReadonly(notes); // true; notes.items and its elements are read-only too
// notes.saving = false; → MJX212
// notes.items.push(draft); → MJX212
- Nested objects, array elements,
Mapvalues andSetelements read from the view are read-only too. - The array's read methods (
map,filter,find,for...of) work and are tracked. - Writing,
delete, methods that change the array (push,splice,sort,reverse) andMap/Setchanges (set,add,delete,clear) throwMJX212, and the data does not change. The error is the same in strict mode and outside it. - The same view is always returned for the same object.
reactive(view)gives the view itself; you cannot get a writable proxy from a view. isReadonly(x)istrueonly for views obtained throughasReadonly. A frozen object (Object.freeze) cannot be changed anyway, so it is returned as it is.- The return type is
DeepReadonly<T>, so assignments and changing methods are caught at compile time as well.
useModel(model)
An alias that does the same job as reactive. Every component also has a useModel(model) method that does the same thing.
const state = useModel({ open: false });
const state = useModel({ open: false });
Map, Set, WeakMap, WeakSet
Collections inside a reactive model are reactive too. You call their methods as usual, and the bindings and effects that read them run again only on the relevant change:
import { reactive } from "@motifx/core";
import type { Note } from "./services/notes";
const board = reactive({
notesById: new Map<number, Note>(),
selected: new Set<number>(),
});
board.notesById.set(note.id, note);
board.selected.add(note.id);
board.notesById.get(note.id)!.title = "Meeting"; // the objects inside are reactive too
board.selected.delete(note.id);
board.notesById.clear();
import { reactive } from "@motifx/core";
const board = reactive({
notesById: new Map(),
selected: new Set(),
});
board.notesById.set(note.id, note);
board.selected.add(note.id);
board.notesById.get(note.id).title = "Meeting"; // the objects inside are reactive too
board.selected.delete(note.id);
board.notesById.clear();
The list and the selection are drawn like an array:
<ul>
{[...board.notesById.values()].map((note) => (
<li key={note.id} class={board.selected.has(note.id) ? "selected" : ""}>{note.title}</li>
))}
</ul>
motifComponent("ul", (ul: ComponentBase) => {
ul.controls.add(motifComponent({
initializeComponent: (holder: ComponentBase) => holder.bindings.list(() => [...board.notesById.values()], (note: Note) =>
motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li: ComponentBase) => {
li.class.add(() => (board.selected.has(note.id) ? "selected" : ""));
li.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title")));
},
})),
}));
});
<ul>
{[...board.notesById.values()].map((note) => (
<li key={note.id} class={board.selected.has(note.id) ? "selected" : ""}>{note.title}</li>
))}
</ul>
motifComponent("ul", (ul) => {
ul.controls.add(motifComponent({
initializeComponent: (holder) => holder.bindings.list(() => [...board.notesById.values()], (note) =>
motifComponent("li", {
indexkey: () => note.id,
initializeComponent: (li) => {
li.class.add(() => (board.selected.has(note.id) ? "selected" : ""));
li.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title")));
},
})),
}));
});
| Read | Writes that run it again |
|---|---|
get(k), has(k) |
Only a set, add, delete or clear that changes that key |
size, forEach, values(), entries(), for…of, [...x] |
Every write that changes the content |
keys() on a Map |
Only adding or deleting a key; changing the value of an existing key does not trigger it |
Writes where nothing actually changes trigger nothing: a set with the same value (Object.is), an add of an existing element, a delete of a missing key, a clear of an empty collection.
- Objects inside:
Mapvalues andSetelements come back as reactive proxies when read, and changes to their fields are tracked.Mapkeys come back raw during iteration. - Raw storage: proxies written to a collection are stored in their raw form. It makes no difference whether you give an object key raw or reactive; both reach the same entry.
- Identity:
reactive(new Map())can be used directly too. The same proxy always comes back for the same collection;instanceof Map, chaining (map.set(a, 1).set(b, 2)) andObject.prototype.toStringkeep their native behaviour. WeakMap/WeakSet: they have only their own methods (get,set,has,add,delete), which are tracked by the same rules.
Derived (computed) fields — a function inside the object
If you define a function field inside a reactive object, it behaves like a derived reactive value:
const filters = reactive({
query: "",
onlyPinned: false,
label: () => (filters.onlyPinned ? `${filters.query} (pinned)` : filters.query), // derived
});
const filters = reactive({
query: "",
onlyPinned: false,
label: () => (filters.onlyPinned ? `${filters.query} (pinned)` : filters.query), // derived
});
<span>{() => filters.label()}</span>
const label = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => filters.label()));
<span>{() => filters.label()}</span>
const label = motifComponent("span", (span) => span.bindings.text(() => filters.label()));
A template slot calls the function value without arguments ({filters.label}, x-text={filters.label}, title={filters.label}) and calls it again whenever the fields it reads change. {filters.label} and title={filters.label} call the function without this, while x-text={filters.label} calls it through filters; that is why the derived field reads filters instead of this. A method that uses this is passed to a slot as {() => filters.method()}. A function that must not run when the binding is set up and whenever the fields it reads change (a Promise resolver, for example) is not given to a template slot; call it from your own code.
Signal and Computed
These are the finest-grained primitives, each wrapping a single value. They suit holding reactive state that is independent of the component tree.
createSignal(initialValue)
import { createSignal } from "@motifx/core";
const draft = createSignal("");
draft.value; // read (tracked)
draft.value = "Meeting notes"; // write (triggers dependents)
draft.peek(); // read without tracking
draft.update((text) => text.trim()); // update with a function
draft.mutate((text) => { /* a mutation that keeps the reference */ });
draft.notify(); // notify listeners even though the reference did not change
draft.asReadonly(); // read-only view
draft.dispose(); // dispose
import { createSignal } from "@motifx/core";
const draft = createSignal("");
draft.value; // read (tracked)
draft.value = "Meeting notes"; // write (triggers dependents)
draft.peek(); // read without tracking
draft.update((text) => text.trim()); // update with a function
draft.mutate((text) => { /* a mutation that keeps the reference */ });
draft.notify(); // notify listeners even though the reference did not change
draft.asReadonly(); // read-only view
draft.dispose(); // dispose
A summary of the Signal class:
| Member | Description |
|---|---|
get value / set value |
A tracked read / a triggering write. |
peek() |
Read without tracking. |
update(fn) |
Update based on the previous value. |
mutate(fn) |
Notify after an inner mutation (when the reference stays the same). |
notify() |
Manual notification. |
asReadonly() |
A ReadonlySignal<T> view (value, peek()). |
dispose() |
Makes later operations a no-op. |
valueOf() / toString() |
A tracked read; gives the value in a template string (`${draft}`) or in string concatenation. |
The equality comparer can be customized (the default is Object.is); for that, construct the Signal class directly or call Signal.create(value, equals):
import { Signal } from "@motifx/core";
const scrollTop = new Signal(0, (a, b) => Math.abs(a - b) < 1); // a difference under 1 px does not count as a write
import { Signal } from "@motifx/core";
const scrollTop = new Signal(0, (a, b) => Math.abs(a - b) < 1); // a difference under 1 px does not count as a write
createComputed(getter)
A cached value derived automatically from its dependencies. It is computed right away at setup; when one of its dependencies changes, it is recomputed on the next microtask even if nobody reads it, and if the value changed it triggers the effects that depend on it. .value gives the cached value and does no computation while being read, so a synchronous read right after a write can give the previous value:
import { createSignal, createComputed } from "@motifx/core";
const title = createSignal("");
const body = createSignal("");
const preview = createComputed(() => title.value.trim() || body.value.slice(0, 40) || "Untitled");
preview.value; // "Untitled" — recomputed when its dependencies change
preview.peek(); // read without tracking
preview.dispose();
import { createSignal, createComputed } from "@motifx/core";
const title = createSignal("");
const body = createSignal("");
const preview = createComputed(() => title.value.trim() || body.value.slice(0, 40) || "Untitled");
preview.value; // "Untitled" — recomputed when its dependencies change
preview.peek(); // read without tracking
preview.dispose();
createLazyComputed(getter) — no computation until it is read
The lazy counterpart of createComputed. It does no computation at setup; when a dependency changes, it is only marked dirty and the effects that depend on it are woken. The computation happens when .value is first read and on every dirty read; a value that is never read is never computed. The marking is synchronous at the time of the write: right after the line items.value = …, index.value gives the fresh value.
import { createSignal, createLazyComputed } from "@motifx/core";
import type { Note } from "./services/notes";
const items = createSignal<Note[]>([]);
const index = createLazyComputed(() => buildSearchIndex(items.value));
items.value = await loadNotes(); // no computation, only the dirty mark
index.isDirty; // true
index.value; // computed now and cached
index.value; // from the cache
index.peek(); // read without tracking (still computes if dirty)
index.dispose(); // stops tracking; later reads return the last value
import { createSignal, createLazyComputed } from "@motifx/core";
const items = createSignal([]);
const index = createLazyComputed(() => buildSearchIndex(items.value));
items.value = await loadNotes(); // no computation, only the dirty mark
index.isDirty; // true
index.value; // computed now and cached
index.value; // from the cache
index.peek(); // read without tracking (still computes if dirty)
index.dispose(); // stops tracking; later reads return the last value
Which one when: if the value is read on screen all the time, createComputed (ready after every change); if it is expensive and read less often than it is written, createLazyComputed (N writes between two reads → 1 computation). Writes in the same synchronous block come down to a single computation with createComputed too (see Consecutive writes). The trade-off: dependent effects wake even when the lazy value does not change, because no computation is done while marking it dirty and equality cannot be known.
Synchronous freshness holds when the whole chain consists of reactive/Signal/LazyComputed. If there is a createComputed in the chain, its refresh happens on a microtask, so a lazy value fed by it is refreshed after the flush, not right after the write.
createSelection(source) — the selected row
If every row of a list compares the selected id with its own id (() => state.selected === note.id), every row's binding wakes and redoes its comparison when the selection changes, even though only two rows have actually changed. createSelection keeps the comparison by key: isSelected(key) subscribes the binding that calls it only to that key, and when the source changes, only the bindings of the old and the new value wake up.
import { reactive, createSelection } from "@motifx/core";
import type { Note } from "../services/notes";
export const state = reactive({ items: [] as Note[], selected: 0 });
export const isSelected = createSelection(() => state.selected);
isSelected(3); // state.selected === 3; inside a binding it subscribes to 3 only
isSelected.dispose(); // stops tracking the source
import { reactive, createSelection } from "@motifx/core";
export const state = reactive({ items: [], selected: 0 });
export const isSelected = createSelection(() => state.selected);
isSelected(3); // state.selected === 3; inside a binding it subscribes to 3 only
isSelected.dispose(); // stops tracking the source
The row gets its selected class through this function:
<li class={{ "is-selected": () => isSelected(note.id) }} onclick={() => { state.selected = note.id; }}>{() => note.title}</li>
const row = motifComponent("li", (li: ComponentBase) => {
li.class.add({ "is-selected": () => isSelected(note.id) });
li.motif.on("click", () => { state.selected = note.id; });
li.bindings.text(() => note.title);
});
<li class={{ "is-selected": () => isSelected(note.id) }} onclick={() => { state.selected = note.id; }}>{() => note.title}</li>
const row = motifComponent("li", (li) => {
li.class.add({ "is-selected": () => isSelected(note.id) });
li.motif.on("click", () => { state.selected = note.id; });
li.bindings.text(() => note.title);
});
- The comparison uses
Object.is. Reactive objects are reduced to their raw object before comparing, soisSelected(note)matches the selected object whatever form it reaches the row in. - Called outside a binding, it only compares and sets up no subscription.
- It is for equality only; a comparison such as
state.selected > note.idcannot be split by key, and in that case a plain getter stays. dispose()stops tracking the source; a selection created in a module next to the data lives along with the data.- Measured on a 60,000-row list: a selection change drops from 49 ms to 0.2 ms, and memory per row stays the same.
effect(fn) — side effects and watchers
An effect runs once, synchronously, the moment it is set up, subscribes to the reactive values read inside it, and runs again whenever they change. effect returns a stop function:
import { effect } from "@motifx/core";
const stop = effect(() => {
document.title = `${notes.count("notes")} notes · Notes`;
});
// end the subscription
stop();
import { effect } from "@motifx/core";
const stop = effect(() => {
document.title = `${notes.count("notes")} notes · Notes`;
});
// end the subscription
stop();
The second argument is an optional result callback: effect(getter, onValue) passes the return value of getter to onValue after every run, and reads inside onValue are not tracked. The first onValue call runs synchronously inside the effect(...) call; if the effect is set up while another effect is running, the reads in that first call are recorded on that outer effect. It is used to limit the dependency surface to getter alone; the framework's when/ternary/switchCase bindings are set up this way. If getter throws, that run has no value: onValue is not called, the error is reported as MJX208 and the effect keeps tracking; the next healthy value still reaches onValue (which is why a when/ternary/switchCase branch stays as it is when an error happens):
const stop = effect(() => settings.state.theme, (theme) => {
// runs only when the theme changes; settings.state.density read here is not tracked
const root = document.documentElement;
if (theme === "system") delete root.dataset.theme;
else root.dataset.theme = theme;
localStorage.setItem("settings", JSON.stringify({ theme, density: settings.state.density }));
});
const stop = effect(() => settings.state.theme, (theme) => {
// runs only when the theme changes; settings.state.density read here is not tracked
const root = document.documentElement;
if (theme === "system") delete root.dataset.theme;
else root.dataset.theme = theme;
localStorage.setItem("settings", JSON.stringify({ theme, density: settings.state.density }));
});
When setting up an effect inside a component, use bindings.watch (see below): the effect stops when the component is disposed, with no manual stop() needed.
Reads after await
Tracking is limited to the synchronous part of an effect: in an async getter/effect, reactive fields read after the first await are not recorded as dependencies. For state that has to update based on the result of async work, write the result to a reactive field and show it with a separate binding that reads that field. The only exception is the dataRequest prop of Virtualization: the compiler wraps the awaits of an async function written inline on a <Virtualization> tag, and reads after await are tracked there too; a function passed by name (dataRequest={loadPage}) or one that is not async is not wrapped (see Virtual Lists). The run-time half of this wrapping is the asyncTracking export; it is not called by hand.
Reading without tracking — untracked
When you want to read a value inside an effect but not subscribe to it, use untracked(fn); it gives the return value of fn. effects set up inside it track their own dependencies as usual.
import { effect, untracked } from "@motifx/core";
effect(() => {
const query = filters.query; // tracked
const density = untracked(() => settings.state.density); // not tracked: this effect does not run when the density changes
localStorage.setItem("lastSearch", JSON.stringify({ query, density }));
});
import { effect, untracked } from "@motifx/core";
effect(() => {
const query = filters.query; // tracked
const density = untracked(() => settings.state.density); // not tracked: this effect does not run when the density changes
localStorage.setItem("lastSearch", JSON.stringify({ query, density }));
});
The core uses this in the list binding (bindings.list): row components are produced inside the list effect, but the reads in the row template and in the row's setup are recorded on the row's own bindings, not on the list; rows added after the first render are set up the same way. That way, when a value a row reads changes, the list binding does not run again for nothing.
Rule: an effect should not write a value it reads.
stats.runs++is both a read and a write; the effect triggers itself again and loops. Put side records such as counters and logs insideuntracked:effect(() => { const count = notes.count("notes"); // tracked read untracked(() => { // reads are not tracked → does not trigger itself stats.runs++; stats.log.unshift(`${count} notes`); }); });
untrackedonly turns off the tracking of reads; writes inside it still trigger their dependents: a binding that showsstats.runsis updated.The framework catches this: if an effect runs 50 times in a single flush, it is skipped for the rest of that flush and an
MJX203warning is printed in development mode. The other effects in the queue are not affected.The array mutators (
push,pop,shift,unshift,splice,reverse) are an exception to this rule: the call's own internal reads (length, indices) are not tracked, so writingstats.log.push(x)inside an effect does not subscribe the effect to the array. If the effect also reads the array (such asstats.log.length), the subscription is still set up.sortis not part of this exception: it reads the array and writes it in place, so if it is called inside an effect, the effect triggers itself; do the sorting insideuntrackedor on a copy ([...stats.log].sort()).
If an effect collects more than 1000 dependencies in a single run, it prints an MJX209 warning in development mode; once per effect, and at most ten times per session. With DevTools open, the warning names the owning component and the binding (StoreRow at [0, 2, 5] (binding:textContent)). This is usually a reactive list being scanned from start to end in every row: find the element by key, or read the list with untracked. The threshold is changed with app.useReactiveMonitor({ dependencies: 2000 }), and 0 turns it off. The second signal is MJX210: if two or more instances of the same component type scan the same reactive list with a search method such as find/some, and the list has at least as many items as there are instances (like the list of rows itself), development mode warns once per list, even if the list comes from props or from another file. A hundred rows scanning a three-item settings list does not warn; for effects without an owner (createComputed, a plain effect) the threshold is eight; with DevTools closed the owner is unknown, so the check does not run. scans: false turns it off.
Chained (derived) state
An effect can read state written by another effect. The whole chain settles in the same microtask; the final value does not depend on the order in which the effects were set up, and there is no need to wait for the intermediate steps:
effect(() => { results.items = notes.list("notes", filters.query); }); // writes
effect(() => { summary.text = `${results.items.length} notes`; }); // reads and writes
filters.query = "work";
// on the next microtask summary.text carries the number of notes that match "work"
effect(() => { results.items = notes.list("notes", filters.query); }); // writes
effect(() => { summary.text = `${results.items.length} notes`; }); // reads and writes
filters.query = "work";
// on the next microtask summary.text carries the number of notes that match "work"
If an effect (or a binding's getter) throws, only that effect is affected; the others in the same flush keep running. The error is reported to the central error handler as a MotifError with the code MJX208, carrying the original error in its cause field: it is written to the console and reaches the errorHandler.addListener(fn) listeners. A binding whose getter throws leaves the last value in the DOM (text, attribute, class, style) as it is.
A watcher tied to the component — bindings.watch / x-watch
The watcher is registered on the component and stops on its own when the component is disposed. The first run happens at registration time, synchronously: a watcher set up in a class's onConfig or in Options' onConfigured runs before onBuilding, while an x-watch on a tag and a bindings.watch in a setup function run for the first time while that tag is being built (inside initializeComponent). QueryField is a search box; it reads the last query from localStorage on startup and writes it there on every change. In the class it is this.bindings.watch inside onConfig, in the function an x-watch on the tag, and in Options this.bindings.watch inside onConfigured; without JSX, all of them are a bindings.watch call:
import { Component, reactive } from "@motifx/core";
export class QueryField extends Component {
state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
override onConfig() {
this.bindings.watch(() => localStorage.setItem("lastQuery", this.state.query));
}
override view() {
return <input type="search" placeholder="Search notes" value={() => this.state.query} oninput={(e) => (this.state.query = (e.target as HTMLInputElement).value)} />;
}
}
import { reactive } from "@motifx/core";
export function QueryField() {
const state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
return (
<input
type="search"
placeholder="Search notes"
value={() => state.query}
oninput={(e) => (state.query = (e.target as HTMLInputElement).value)}
x-watch={() => localStorage.setItem("lastQuery", state.query)}
/>
);
}
import { ComponentBase, motifOptions, reactive } from "@motifx/core";
export const QueryField = () => motifOptions({
el: "input",
data: reactive({ query: localStorage.getItem("lastQuery") ?? "" }),
onConfigured() {
const state = this.data;
this.attr.add({ type: "search", placeholder: "Search notes" });
this.bindings.add("value", () => state.query);
this.motif.on("input", (_sender: ComponentBase, e: Event) => (state.query = (e.target as HTMLInputElement).value));
this.bindings.watch(() => localStorage.setItem("lastQuery", state.query));
},
});
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
export class QueryField extends Component {
state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
override onConfig() {
this.bindings.watch(() => localStorage.setItem("lastQuery", this.state.query));
}
override view() {
return motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => this.state.query);
input.motif.on("input", (_sender: ComponentBase, e: Event) => (this.state.query = (e.target as HTMLInputElement).value));
});
}
}
import { ComponentBase, motifComponent, reactive } from "@motifx/core";
export function QueryField() {
const state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
return motifComponent("input", (input: ComponentBase) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => state.query);
input.motif.on("input", (_sender: ComponentBase, e: Event) => (state.query = (e.target as HTMLInputElement).value));
input.bindings.watch(() => localStorage.setItem("lastQuery", state.query));
});
}
import { ComponentBase, motifOptions, reactive } from "@motifx/core";
export const QueryField = () => motifOptions({
el: "input",
data: reactive({ query: localStorage.getItem("lastQuery") ?? "" }),
onConfigured() {
const state = this.data;
this.attr.add({ type: "search", placeholder: "Search notes" });
this.bindings.add("value", () => state.query);
this.motif.on("input", (_sender: ComponentBase, e: Event) => (state.query = (e.target as HTMLInputElement).value));
this.bindings.watch(() => localStorage.setItem("lastQuery", state.query));
},
});
import { Component, reactive } from "@motifx/core";
export class QueryField extends Component {
state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
onConfig() {
this.bindings.watch(() => localStorage.setItem("lastQuery", this.state.query));
}
view() {
return <input type="search" placeholder="Search notes" value={() => this.state.query} oninput={(e) => (this.state.query = e.target.value)} />;
}
}
import { reactive } from "@motifx/core";
export function QueryField() {
const state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
return (
<input
type="search"
placeholder="Search notes"
value={() => state.query}
oninput={(e) => (state.query = e.target.value)}
x-watch={() => localStorage.setItem("lastQuery", state.query)}
/>
);
}
import { motifOptions, reactive } from "@motifx/core";
export const QueryField = () => motifOptions({
el: "input",
data: reactive({ query: localStorage.getItem("lastQuery") ?? "" }),
onConfigured() {
const state = this.data;
this.attr.add({ type: "search", placeholder: "Search notes" });
this.bindings.add("value", () => state.query);
this.motif.on("input", (_sender, e) => (state.query = e.target.value));
this.bindings.watch(() => localStorage.setItem("lastQuery", state.query));
},
});
import { Component, motifComponent, reactive } from "@motifx/core";
export class QueryField extends Component {
state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
onConfig() {
this.bindings.watch(() => localStorage.setItem("lastQuery", this.state.query));
}
view() {
return motifComponent("input", (input) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => this.state.query);
input.motif.on("input", (_sender, e) => (this.state.query = e.target.value));
});
}
}
import { motifComponent, reactive } from "@motifx/core";
export function QueryField() {
const state = reactive({ query: localStorage.getItem("lastQuery") ?? "" });
return motifComponent("input", (input) => {
input.attr.add({ type: "search", placeholder: "Search notes" });
input.bindings.add("value", () => state.query);
input.motif.on("input", (_sender, e) => (state.query = e.target.value));
input.bindings.watch(() => localStorage.setItem("lastQuery", state.query));
});
}
import { motifOptions, reactive } from "@motifx/core";
export const QueryField = () => motifOptions({
el: "input",
data: reactive({ query: localStorage.getItem("lastQuery") ?? "" }),
onConfigured() {
const state = this.data;
this.attr.add({ type: "search", placeholder: "Search notes" });
this.bindings.add("value", () => state.query);
this.motif.on("input", (_sender, e) => (state.query = e.target.value));
this.bindings.watch(() => localStorage.setItem("lastQuery", state.query));
},
});
The trap of every row scanning the list
Trap: a getter that scans a reactive list in every row. If a row component holds only an id and looks up its element with a getter such as
notes.items.find((n) => n.id === this.props.id), that getter reads theidfield of every element up to the match and subscribes to all of them. In a single row that is n subscriptions; in n rows it is n² subscriptions. When oneidchanges, every row that walked past it runs again, and when the list is replaced with a new one, every row runs again, each scanning the list from the start: n² reads and n² new subscriptions; appending at the end wakes only the rows that had not found their match. Measured with 2000 rows and a 2000-element list: 2 million subscriptions, 1.2 s for the first build, 0.8 s for changing oneid, 4 s for replacing the list.
The reason is that reactivity is fine-grained: every field read is a subscription, and every scanned element is a field read. The scan itself is cheap; what is expensive is every row subscribing to every element. includes and indexOf do not read element fields, so they do not fall into this trap, while a "full walk" such as filter(...).length makes it bigger.
The framework points this out in four places: the compiler warns with MJX004 when it sees find/findIndex/some/every with a predicate of the form x.field === value inside a reactive getter; at run time, if two or more instances of the same component type scan the same list with a search method and the list has at least as many items as there are instances, it is reported with MJX210, even when the list comes from props or from another file; an effect that collects more than 1000 dependencies in a single run is reported with MJX209, together with the owning component and binding when DevTools is open; and in DevTools the component's effect list shows the dependency count of every binding.
Ways to avoid it, in order of preference:
- Give the row the element, not the id. The list binding already does this:
{notes.items.map((note) => <NoteRow note={note} />)}; the row readsthis.props.note.titleand subscribes only to that field of that object (see Showing lists). - If you only have the id, keep a keyed index. The store that holds the list also keeps the elements in a
Map; the row readsnotes.byId(id)and subscribes only to that key, and when the list changes only the reader of that key wakes up:
import { reactive } from "@motifx/core";
import type { Note } from "../services/notes";
const state = reactive({ items: [] as Note[], byId: new Map<number, Note>() });
export const notes = {
get items(): readonly Note[] {
return state.items;
},
byId(id: number): Note | undefined {
return state.byId.get(id);
},
add(note: Note): void {
state.items.push(note);
state.byId.set(note.id, note);
},
remove(id: number): void {
const index = state.items.findIndex((note) => note.id === id);
if (index >= 0) state.items.splice(index, 1);
state.byId.delete(id);
},
};
import { reactive } from "@motifx/core";
const state = reactive({ items: [], byId: new Map() });
export const notes = {
get items() {
return state.items;
},
byId(id) {
return state.byId.get(id);
},
add(note) {
state.items.push(note);
state.byId.set(note.id, note);
},
remove(id) {
const index = state.items.findIndex((note) => note.id === id);
if (index >= 0) state.items.splice(index, 1);
state.byId.delete(id);
},
};
If the list changes rarely, the index can also be a computed: const index = createComputed(() => new Map(state.items.map((n) => [n.id, n]))). The scan happens once and the rows read index.value.get(id); every time the list changes, all rows run again once, but each run is a single read.
3. untracked if no subscription is needed. A getter that only wants the current value reads the list with untracked(() => notes.items.find((n) => n.id === id)); when the list changes this getter is not refreshed, and some other dependency of it makes it run again.
Consecutive writes are batched on their own
Writes made in the same synchronous block need no separate batching call. Writes add effects to a queue; the queue is emptied on the next microtask, and each effect runs once. No effect sees the intermediate states:
filters.query = "work";
filters.onlyPinned = true;
settings.state.density = "compact";
// an effect that reads all three runs only once, on the microtask
filters.query = "work";
filters.onlyPinned = true;
settings.state.density = "compact";
// an effect that reads all three runs only once, on the microtask
The flush happens in rounds: if an effect also reads a value written in the same flush by another effect or by a createComputed, it runs again one round later, in the same microtask. Depending on the order things were set up, its first run may see the old version of that value; its last run sees the current values.
Helpers
| Function | Description |
|---|---|
deepClone(value) |
Produces a non-reactive deep copy of the value; if a proxy is given, its raw form is copied. Plain objects, arrays, Map and Set are copied recursively; Date and RegExp become new instances; a class instance keeps its prototype, and its getters and methods work on the copy; circular references set up the same cycle in the copy. Functions, WeakMap and WeakSet are passed by the same reference, and a DOM node is copied with cloneNode(true). The objects' own enumerable fields are copied. |
clearModel(model) |
Deletes the proxy record of the object and of the nested objects whose proxies have been created; it does not reset the values and does not stop the bindings and effects that are set up. After the call, reactive(model) gives a new proxy, and writing through it still updates the existing bindings. Writing to the fields of a proxy obtained before the call changes the value but does not notify; nested objects read later from that proxy come back as new proxies and do notify. |
read(value) / toGetter(value) |
Read a value of type Bind<T> (a value, or a getter that returns it): read calls the value and gives the result if it is a function, and gives the value itself otherwise; toGetter gives a getter in every case. Used in components that can also take a prop as a getter (see If the template will change later, write a getter). |
configureReactivityLeakMonitor({ enabled, threshold, name }) |
Prints an MJX507 warning when the number of effects tracking a single field reaches threshold (200 by default when enabled); in an application, app.useReactiveMonitor(...) is the same call (see Leak tracking). |
Where reactivity meets the DOM
Reactive fields show up in the DOM through bindings. When you write JSX the compiler sets them up; without JSX you write the same calls yourself:
<span>{note.title}</span>
<li class={() => (note.pinned ? "note pinned" : "note")} />
<input value={() => filters.query} />
const title = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.bindings.add("textContent", note, "title"))));
const row = motifComponent("li", (li: ComponentBase) => li.class.add(() => (note.pinned ? "note pinned" : "note")));
const field = motifComponent("input", (input: ComponentBase) => input.bindings.add("value", () => filters.query));
<span>{note.title}</span>
<li class={() => (note.pinned ? "note pinned" : "note")} />
<input value={() => filters.query} />
const title = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.bindings.add("textContent", note, "title"))));
const row = motifComponent("li", (li) => li.class.add(() => (note.pinned ? "note pinned" : "note")));
const field = motifComponent("input", (input) => input.bindings.add("value", () => filters.query));
setState() / reState() re-evaluate the bindings of a component and all of its children: text and attribute paths, class and style getters, x-text, x-html, value/checked/x-model, x-display/x-wait, {() => …}, template string and member call slots, &&/ternary/switch branches and x-watch/bindings.watch watchers run again; lists match their rows to the items again, just as on a reactive update: the row of an item that stays in the list stays in place and reads its own bindings again, a row is built for a new item, the row of an item that left is disposed, and if the order changed the rows swap places. Only the order differs: setState() handles the children first and then the component itself, while reState() handles the component itself first and then the children.
this.setState(); // re-evaluate the bindings of this component and its children
list.setState(); // re-evaluate the bindings of this component and its children
this.setState(); // re-evaluate the bindings of this component and its children
list.setState(); // re-evaluate the bindings of this component and its children
In a function component these calls are made on the component itself (list 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 working with reactive there is no need for setState; fine-grained reactivity does the update itself. setState is for cases where you update plain, non-reactive data by hand; for example, making every slot read again when the fields of a plain language dictionary are changed in place. If you replace the dictionary object with another object, a path written directly ({i18n.dict.title}) stays on the old object read during setup, while a getter slot ({() => i18n.dict.title}) reads the new object.
Next step
To learn conditional and list-based rendering, go on to Conditional Rendering and Lists.