Application structure

Memory Management and Disposal

What is released on its own, motif.setDisposable/register, the dispose methods, detach/silentDetach/silentUnlink, DisposableStore, using/doWork, leak tracking and counting live components.

In MotifJS every element is a live component: it holds its own event listeners, bindings, reactive subscriptions and children. When the component is disposed, these are released together with it. Resources opened outside the component (a timer, a listener added to the document, an observer, a connection), on the other hand, you tie to the component yourself.

What is released on its own

While a component is being disposed, MotifJS does the following itself:

No manual cleanup is needed for the events, bindings and children the component sets up itself.

Resources tied by hand

The component does not know about a resource opened outside it: setInterval, a document or window listener, ResizeObserver, WebSocket. These are tied to the component's lifetime through the motif namespace; this.motif in a class and in Options, and the root's motif in a function component.

motif.setDisposable(fn) and motif.register(object)

A note row's date is written with formatDay(note.updatedAt, now): the time for today, "Yesterday" for yesterday, and the day for anything older. The component in the example refreshes now once a minute so the text changes when the day turns over; the timer stops when the component is disposed:

import { Component, reactive } from "@motifx/core";
import { formatDay } from "../format";
import type { Note } from "../services/notes";

export class NoteTime extends Component<HTMLTimeElement, { note: Note }> {
  state = reactive({ now: Date.now() });

  override onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  }

  override view() {
    return <>{() => formatDay(this.props.note.updatedAt, this.state.now)}</>;
  }
}
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
import { formatDay } from "../format";
import type { Note } from "../services/notes";

export class NoteTime extends Component<HTMLTimeElement, { note: Note }> {
  static override elementTag = "time";
  state = reactive({ now: Date.now() });

  override onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  }

  override view() {
    return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => formatDay(this.props.note.updatedAt, this.state.now)));
  }
}
import { reactive, type ComponentBase } from "@motifx/core";
import { formatDay } from "../format";
import type { Note } from "../services/notes";

export function NoteTime(props: { note: Note }) {
  const state = reactive({ now: Date.now() });

  const start = (time: ComponentBase) => {
    const timer = setInterval(() => (state.now = Date.now()), 60_000);
    time.motif.setDisposable(() => clearInterval(timer));
  };

  return <time onConfig={start}>{() => formatDay(props.note.updatedAt, state.now)}</time>;
}
import { motifComponent, reactive, type ComponentBase } from "@motifx/core";
import { formatDay } from "../format";
import type { Note } from "../services/notes";

export function NoteTime(props: { note: Note }) {
  const state = reactive({ now: Date.now() });

  return motifComponent("time", (time: ComponentBase) => {
    const timer = setInterval(() => (state.now = Date.now()), 60_000);
    time.motif.setDisposable(() => clearInterval(timer));
    time.bindings.text(() => formatDay(props.note.updatedAt, state.now));
  });
}
import { motifOptions, reactive } from "@motifx/core";
import { formatDay } from "../format";
import type { Note } from "../services/notes";

export const NoteTime = (props: { note: Note }) => motifOptions({
  el: "time",
  state: reactive({ now: Date.now() }),

  onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  },

  view() {
    return <>{() => formatDay(props.note.updatedAt, this.state.now)}</>;
  },
});
import { ComponentBase, motifComponent, motifOptions, reactive } from "@motifx/core";
import { formatDay } from "../format";
import type { Note } from "../services/notes";

export const NoteTime = (props: { note: Note }) => motifOptions({
  el: "time",
  state: reactive({ now: Date.now() }),

  onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  },

  view() {
    return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => formatDay(props.note.updatedAt, this.state.now)));
  },
});
import { Component, reactive } from "@motifx/core";
import { formatDay } from "../format";

export class NoteTime extends Component {
  static elementTag = "time";
  state = reactive({ now: Date.now() });

  onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  }

  view() {
    return <>{() => formatDay(this.props.note.updatedAt, this.state.now)}</>;
  }
}
import { Component, motifComponent, reactive } from "@motifx/core";
import { formatDay } from "../format";

export class NoteTime extends Component {
  static elementTag = "time";
  state = reactive({ now: Date.now() });

  onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  }

  view() {
    return motifComponent("text", (node) => node.bindings.text(() => formatDay(this.props.note.updatedAt, this.state.now)));
  }
}
import { reactive } from "@motifx/core";
import { formatDay } from "../format";

export function NoteTime(props) {
  const state = reactive({ now: Date.now() });

  const start = (time) => {
    const timer = setInterval(() => (state.now = Date.now()), 60_000);
    time.motif.setDisposable(() => clearInterval(timer));
  };

  return <time onConfig={start}>{() => formatDay(props.note.updatedAt, state.now)}</time>;
}
import { motifComponent, reactive } from "@motifx/core";
import { formatDay } from "../format";

export function NoteTime(props) {
  const state = reactive({ now: Date.now() });

  return motifComponent("time", (time) => {
    const timer = setInterval(() => (state.now = Date.now()), 60_000);
    time.motif.setDisposable(() => clearInterval(timer));
    time.bindings.text(() => formatDay(props.note.updatedAt, state.now));
  });
}
import { motifOptions, reactive } from "@motifx/core";
import { formatDay } from "../format";

export const NoteTime = (props) => motifOptions({
  el: "time",
  state: reactive({ now: Date.now() }),

  onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  },

  view() {
    return <>{() => formatDay(props.note.updatedAt, this.state.now)}</>;
  },
});
import { motifComponent, motifOptions, reactive } from "@motifx/core";
import { formatDay } from "../format";

export const NoteTime = (props) => motifOptions({
  el: "time",
  state: reactive({ now: Date.now() }),

  onConfigured() {
    const timer = setInterval(() => (this.state.now = Date.now()), 60_000);
    this.motif.setDisposable(() => clearInterval(timer));
  },

  view() {
    return motifComponent("text", (node) => node.bindings.text(() => formatDay(props.note.updatedAt, this.state.now)));
  },
});

The row places the date with <NoteTime note={note} />.

The row places the date with new NoteTime({ note }).

The row places the date with motifComponent(NoteTime, { note }).

Application event subscriptions

A subscription the component opens with context.on(...) is tied to the component's lifetime on its own and goes away when the component is disposed. To release it earlier, call the function it returns:

const off = this.context.on("notes-imported", () => (this.state.query = ""));
off();
const off = list.context.on("notes-imported", () => (state.query = ""));
off();
const off = this.context.on("notes-imported", () => (this.state.query = ""));
off();
const off = list.context.on("notes-imported", () => (state.query = ""));
off();

In a function component the calls on this page are made on the component itself (list in the examples): 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.

A subscription opened outside the component, through the application (app.on(...), Application.main.on(...)), is not tied to any component; give the function it returns to a component with motif.setDisposable, or call it yourself.

Cleaning up an effect

effect(...) is not tied to any component; to stop it, call the function it returns. For a watcher that belongs to a component, bindings.watch does the same job and stops together with the component:

this.bindings.watch(() => localStorage.setItem("notes.query", this.state.query));

const stop = effect(() => localStorage.setItem("notes.query", this.state.query));
this.motif.setDisposable(stop);
list.bindings.watch(() => localStorage.setItem("notes.query", state.query));

const stop = effect(() => localStorage.setItem("notes.query", state.query));
list.motif.setDisposable(stop);
this.bindings.watch(() => localStorage.setItem("notes.query", this.state.query));

const stop = effect(() => localStorage.setItem("notes.query", this.state.query));
this.motif.setDisposable(stop);
list.bindings.watch(() => localStorage.setItem("notes.query", state.query));

const stop = effect(() => localStorage.setItem("notes.query", state.query));
list.motif.setDisposable(stop);

The two forms give the same result: in the first, the component releases the watcher, and in the second, you hand the stop function to the component yourself.

The dispose methods

await this.dispose();                                 // with the leave transition; references in the subtree are cleared too
await this.dispose({ skipLeaveTransition: true });    // without playing the leave transition
await this.dispose({ deep: false });                  // reference cleanup in the children is skipped
await this.disposeAsync();                            // the leave transition is not played, running animations are stopped
await list.dispose();                                 // with the leave transition; references in the subtree are cleared too
await list.dispose({ skipLeaveTransition: true });    // without playing the leave transition
await list.dispose({ deep: false });                  // reference cleanup in the children is skipped
await list.disposeAsync();                            // the leave transition is not played, running animations are stopped
await this.dispose();                                 // with the leave transition; references in the subtree are cleared too
await this.dispose({ skipLeaveTransition: true });    // without playing the leave transition
await this.dispose({ deep: false });                  // reference cleanup in the children is skipped
await this.disposeAsync();                            // the leave transition is not played, running animations are stopped
await list.dispose();                                 // with the leave transition; references in the subtree are cleared too
await list.dispose({ skipLeaveTransition: true });    // without playing the leave transition
await list.dispose({ deep: false });                  // reference cleanup in the children is skipped
await list.disposeAsync();                            // the leave transition is not played, running animations are stopped

For details of the options, see Disposal. isDisposed tells whether the component has been disposed; calling dispose() again on a disposed component does nothing, and motif calls do not throw.

Children are disposed through controls as well:

this.controls.remove(child);   // removes child and disposes it
this.controls.clear();         // disposes every child
await this.motif.clear();      // disposes every child, resolves when all are done
list.controls.remove(child);   // removes child and disposes it
list.controls.clear();         // disposes every child
await list.motif.clear();      // disposes every child, resolves when all are done
this.controls.remove(child);   // removes child and disposes it
this.controls.clear();         // disposes every child
await this.motif.clear();      // disposes every child, resolves when all are done
list.controls.remove(child);   // removes child and disposes it
list.controls.clear();         // disposes every child
await list.motif.clear();      // disposes every child, resolves when all are done

motif.clear() does nothing if motif.options.disableDisposal is on; controls.clear() ignores this setting.

Moving without disposing

remove() and clear() dispose the child: its bindings, watchers, listeners and DOM go away. To move a component somewhere else without disposing it, or to reuse it later, use one of these three:

Method Collection Leave animation DOM Notification
detach(child) removes plays it: the element's own leave if the root is an element; if the root is a fragment, the leaves of its visible element children (nested fragments included) together removes it when the leave finishes (for a fragment root, the whole range from opening to closing; if hidden, the placeholder); synchronous if no transition is defined controlremoved on the parent after the leave finishes and the DOM is removed
silentDetach(child) removes does not play removes synchronously none
silentUnlink(child) removes does not play does not touch none

All three leave the component and its children alive. detach and silentDetach send onDeactivated to the component and its visible subtree when the DOM is removed, and onActivated when the component is placed again with controls.add; silentUnlink does not touch the DOM, so it sends no notification (Lifecycle). detach returns a Promise; if there is a transition it resolves when the removal is finished, otherwise it comes back already resolved:

await this.controls.detach(child);   // leave has played, the DOM is detached
other.controls.add(child);           // moved to its new place
await list.controls.detach(child);   // leave has played, the DOM is detached
other.controls.add(child);           // moved to its new place
await this.controls.detach(child);   // leave has played, the DOM is detached
other.controls.add(child);           // moved to its new place
await list.controls.detach(child);   // leave has played, the DOM is detached
other.controls.add(child);           // moved to its new place

Writing other.controls.add(child) without waiting is safe too: detach from the old parent happens on its own, the collection and parent are updated right away, and the DOM is attached to the new parent when the leave finishes. The other ways of removing with animation are motif.hide() and dispose().

DisposableStore — cleaning up a group

Several resources are collected in a DisposableStore and disposed together:

import { DisposableStore, disposableCore } from "@motifx/core";

const store = new DisposableStore();
const timer = setInterval(() => (state.now = Date.now()), 60_000);
const tick = store.add(disposableCore.toDisposable(() => clearInterval(timer)));   // add returns what it added
const observer = new ResizeObserver(() => autosize(area));
store.add(disposableCore.toDisposable(() => observer.disconnect()));

store.delete(tick);   // removes one and disposes it
store.detach(tick);   // removes one, does not dispose it
store.clear();        // disposes them all, the store stays open
store.dispose();      // disposes them all and closes the store
store.isDisposed;     // true
import { DisposableStore, disposableCore } from "@motifx/core";

const store = new DisposableStore();
const timer = setInterval(() => (state.now = Date.now()), 60_000);
const tick = store.add(disposableCore.toDisposable(() => clearInterval(timer)));   // add returns what it added
const observer = new ResizeObserver(() => autosize(area));
store.add(disposableCore.toDisposable(() => observer.disconnect()));

store.delete(tick);   // removes one and disposes it
store.detach(tick);   // removes one, does not dispose it
store.clear();        // disposes them all, the store stays open
store.dispose();      // disposes them all and closes the store
store.isDisposed;     // true

If you add to a store that has been closed, the added object does not enter the store and is not disposed (it leaks); in development mode an MJX504 warning is written. If several items throw during clear/dispose, all of them are gathered in an AggregateError in the cause of a single MJX503 error; a single error is thrown as it is.

A store is tied to a component with motif.register(store).

Safety after disposal: using and doWork

When an async job finishes, the component may have been disposed and its fields cleared. using(promise, onfulfilled?, onrejected?) passes the result to the handlers only if the component is alive, and returns nothing (see Async Results with using). doWork(promise) returns a promise; if the component has been disposed, a MotifError with the code MJX108 arrives in place of the value or the rejection (see Async Results with doWork).

Leak tracking

app.useReactiveMonitor({ enabled: true, threshold: 200, name: "notes" });
app.useReactiveMonitor({ enabled: true, threshold: 200, name: "notes" });

When the number of effects tracking a single field of a reactive object reaches threshold (200 if not given), an MJX507 warning is written; the warning also shows the most frequent setup stack. This usually means that the bindings of components that were never disposed are piling up. The warning is written only in development mode. Tracking is off by default; turn it on during development.

Counting live components — disposableCore.disposableTracker

Every component is a Disposable: its constructor calls trackDisposable, and the disposal chain calls markAsDisposed. By attaching a tracker to disposableCore.disposableTracker, the number of live components is measured; it is used in round-trip tests ("after building and tearing down N times, did the counter go back to where it started?"):

import { disposableCore, ComponentBase } from "@motifx/core";

let alive = 0;
disposableCore.disposableTracker = {
  trackDisposable(x) { if (x instanceof ComponentBase) alive++; },
  markAsDisposed(x) { if (x instanceof ComponentBase) alive--; },
  setParent() { },
  markAsSingleton() { },
};
import { disposableCore, ComponentBase } from "@motifx/core";

let alive = 0;
disposableCore.disposableTracker = {
  trackDisposable(x) { if (x instanceof ComponentBase) alive++; },
  markAsDisposed(x) { if (x instanceof ComponentBase) alive--; },
  setParent() { },
  markAsSingleton() { },
};

Next to components, the tracker also receives stores and toDisposable objects; the instanceof check in the example counts components only. markAsDisposed arrives once per object; it is not reported again even if dispose() is called again. To inspect the dependency maps of reactive objects, use debugGetDeps(target) and debugGetDepMap() from the @motifx/core/devtools subpath (import { debugGetDeps } from "@motifx/core/devtools"). In development mode, __MOTIF_DEVTOOLS__.getCounters().components.notInTree counts components that are alive but not in the tree; if it does not drop after leaving a page, those components are being held somewhere (The DevTools Contract).

Best practices

Next step

To learn how errors are thrown, reported and caught, go on to Error Handling.