Guides

Wrapping Third-Party Libraries

Rules for turning libraries that manage their own DOM into components; setup in onBuilt, cleanup with setDisposable, untracked writes, DOM ownership conflicts, the root element and the single-copy rule.

In real applications not everything is written in MotifJS. Work such as charts, rich text editors, maps and drag and drop is left to mature libraries that manage their own DOM. This section covers the rules for turning those libraries into MotifJS components; the examples use Chart.js and SortableJS.

First decide: do you need to wrap it?

Wrap it:

Do not wrap it:

Four rules

The rules are explained on the same example: a chart that draws the number of notes in each folder with Chart.js.

1. Setup inside onBuilt or initializeComponent

The library instance is set up the moment the element is created: in the onBuilt or initializeComponent hook. The element exists on the component at that moment; it does not have to be attached to the document. onMounted is only for cases where the library wants the element in the document: libraries that take measurements, read layout or want a live DOM. onBuilt can fire while the element is still inside a detached piece (ternary and list branches are built in a detached fragment), and these libraries give wrong results there. onMounted runs once, in the task after the element is attached to document; the setup of several wrappers is split into slices and does not lock up the page.

import { Component, untracked } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService, type Folder } from "../services/notes";

const folders = Object.keys(FOLDERS) as Folder[];

export class FolderChart extends Component<HTMLCanvasElement> {
  static override elementTag = "canvas";
  notes = this.getService(NotesService)!;

  override onBuilt() {
    const chart = new Chart(this.element, {
      type: "bar",
      data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] as number[] }] },
    });
    this.motif.setDisposable(() => chart.destroy());

    this.bindings.watch(() => {
      const counts = folders.map((folder) => this.notes.count(folder));
      untracked(() => {
        chart.data.datasets[0].data = counts;
        chart.update();
      });
    });
  }
}
import { FromService, untracked, type Component } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService, type Folder } from "../services/notes";

const folders = Object.keys(FOLDERS) as Folder[];

export function FolderChart() {
  const notes = FromService(NotesService)!;

  const setup = (canvas: Component<HTMLCanvasElement>) => {
    const chart = new Chart(canvas.element, {
      type: "bar",
      data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] as number[] }] },
    });
    canvas.motif.setDisposable(() => chart.destroy());

    canvas.bindings.watch(() => {
      const counts = folders.map((folder) => notes.count(folder));
      untracked(() => {
        chart.data.datasets[0].data = counts;
        chart.update();
      });
    });
  };

  return <canvas onBuilt={setup} />;
}
import { FromService, motifComponent, untracked, type Component } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService, type Folder } from "../services/notes";

const folders = Object.keys(FOLDERS) as Folder[];

export function FolderChart() {
  const notes = FromService(NotesService)!;

  return motifComponent("canvas", {
    onBuilt: (canvas: Component<HTMLCanvasElement>) => {
      const chart = new Chart(canvas.element, {
        type: "bar",
        data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] as number[] }] },
      });
      canvas.motif.setDisposable(() => chart.destroy());

      canvas.bindings.watch(() => {
        const counts = folders.map((folder) => notes.count(folder));
        untracked(() => {
          chart.data.datasets[0].data = counts;
          chart.update();
        });
      });
    },
  });
}
import { motifOptions, untracked } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService, type Folder } from "../services/notes";

const folders = Object.keys(FOLDERS) as Folder[];

export const FolderChart = () => motifOptions({
  el: "canvas",

  onBuilt() {
    const notes = this.getService(NotesService)!;
    const chart = new Chart(this.element as HTMLCanvasElement, {
      type: "bar",
      data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] as number[] }] },
    });
    this.motif.setDisposable(() => chart.destroy());

    this.bindings.watch(() => {
      const counts = folders.map((folder) => notes.count(folder));
      untracked(() => {
        chart.data.datasets[0].data = counts;
        chart.update();
      });
    });
  },
});
import { Component, untracked } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService } from "../services/notes";

const folders = Object.keys(FOLDERS);

export class FolderChart extends Component {
  static elementTag = "canvas";
  notes = this.getService(NotesService);

  onBuilt() {
    const chart = new Chart(this.element, {
      type: "bar",
      data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] }] },
    });
    this.motif.setDisposable(() => chart.destroy());

    this.bindings.watch(() => {
      const counts = folders.map((folder) => this.notes.count(folder));
      untracked(() => {
        chart.data.datasets[0].data = counts;
        chart.update();
      });
    });
  }
}
import { FromService, untracked } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService } from "../services/notes";

const folders = Object.keys(FOLDERS);

export function FolderChart() {
  const notes = FromService(NotesService);

  const setup = (canvas) => {
    const chart = new Chart(canvas.element, {
      type: "bar",
      data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] }] },
    });
    canvas.motif.setDisposable(() => chart.destroy());

    canvas.bindings.watch(() => {
      const counts = folders.map((folder) => notes.count(folder));
      untracked(() => {
        chart.data.datasets[0].data = counts;
        chart.update();
      });
    });
  };

  return <canvas onBuilt={setup} />;
}
import { FromService, motifComponent, untracked } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService } from "../services/notes";

const folders = Object.keys(FOLDERS);

export function FolderChart() {
  const notes = FromService(NotesService);

  return motifComponent("canvas", {
    onBuilt: (canvas) => {
      const chart = new Chart(canvas.element, {
        type: "bar",
        data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] }] },
      });
      canvas.motif.setDisposable(() => chart.destroy());

      canvas.bindings.watch(() => {
        const counts = folders.map((folder) => notes.count(folder));
        untracked(() => {
          chart.data.datasets[0].data = counts;
          chart.update();
        });
      });
    },
  });
}
import { motifOptions, untracked } from "@motifx/core";
import { Chart } from "chart.js/auto";
import { FOLDERS, NotesService } from "../services/notes";

const folders = Object.keys(FOLDERS);

export const FolderChart = () => motifOptions({
  el: "canvas",

  onBuilt() {
    const notes = this.getService(NotesService);
    const chart = new Chart(this.element, {
      type: "bar",
      data: { labels: folders.map((folder) => FOLDERS[folder].title), datasets: [{ label: "Notes", data: [] }] },
    });
    this.motif.setDisposable(() => chart.destroy());

    this.bindings.watch(() => {
      const counts = folders.map((folder) => notes.count(folder));
      untracked(() => {
        chart.data.datasets[0].data = counts;
        chart.update();
      });
    });
  },
});

The chart is placed with <FolderChart />.

The chart is placed with new FolderChart().

The chart is placed with motifComponent(FolderChart).

2. Cleanup with motif.setDisposable

The library's destroy() / disconnect() call is tied to the component's lifetime; in the example chart.destroy() is tied this way. When the component is disposed, the library goes too, and nothing else needs to be called. Side resources such as timers, ResizeObserver and WebSocket are registered in the same place (see Memory Management and Disposal).

3. Reads are tracked, writes happen inside untracked

There are two traps when passing reactive data to a library:

The right form is the one in the example: inside bindings.watch, a plain copy (counts) is taken in the tracked region, and the write to the library is done inside untracked. The copy catches deep changes too: when a note is pinned, count("pinned") reads the notes' pinned field, so the effect runs again and the chart updates.

4. Setup errors are reported

An exception thrown from inside onBuilt (or the rejected promise of an async onBuilt) is not swallowed: MotifJS reports it to errorHandler as a MotifError with the code MJX122 (The component onBuilt hook 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 same report is produced for the other lifecycle hooks, onMounted included. The wrapper does not need to write console.error itself.

Leaving the component in a consistent state when setup fails (tearing down the half-built instance, giving a fallback view) is the wrapper's job; for that, the error is caught, handled and then rethrown, and the report still happens. config is the chart's settings object:

let chart: Chart;
try {
  chart = new Chart(this.element as HTMLCanvasElement, config);
} catch (error) {
  this.class.add("chart--failed");
  throw error;
}
this.motif.setDisposable(() => chart.destroy());
let chart: Chart;
try {
  chart = new Chart(canvas.element, config);
} catch (error) {
  canvas.class.add("chart--failed");
  throw error;
}
canvas.motif.setDisposable(() => chart.destroy());
let chart;
try {
  chart = new Chart(this.element, config);
} catch (error) {
  this.class.add("chart--failed");
  throw error;
}
this.motif.setDisposable(() => chart.destroy());
let chart;
try {
  chart = new Chart(canvas.element, config);
} catch (error) {
  canvas.class.add("chart--failed");
  throw error;
}
canvas.motif.setDisposable(() => chart.destroy());

DOM ownership conflicts

The hardest case is when the library moves nodes that MotifJS drew. Drag-and-drop libraries do exactly this: at the moment of the drop they reorder the DOM themselves. But those nodes are owned by the list binding; if the two owners clash, the binding can no longer calculate correctly.

The solution pattern:

  1. Undo the library's DOM change.
  2. Apply the same change to the model.
  3. Let MotifJS draw the DOM again.

Since the effect queue is emptied on a microtask, the undo and the redraw complete in the same frame; there is no flicker on screen.

The simplest form, for a single list with SortableJS:

import { Component, read, type Bind } from "@motifx/core";
import Sortable from "sortablejs";

export class SortableList<T> extends Component<HTMLDivElement, { list: Bind<T[]> }> {
  static override elementTag = "div";

  override onBuilt() {
    const sortable = Sortable.create(this.element, { animation: 150, onEnd: (evt) => this.dropped(evt) });
    this.motif.setDisposable(() => sortable.destroy());
  }

  private dropped(evt: Sortable.SortableEvent) {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(this.props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  }
}
import { read, type Bind, type Component, type ComponentBase } from "@motifx/core";
import Sortable from "sortablejs";

export function SortableList<T>(props: { list: Bind<T[]>; childs?: ComponentBase[] }) {
  const dropped = (evt: Sortable.SortableEvent) => {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  };

  const setup = (box: Component<HTMLDivElement>) => {
    const sortable = Sortable.create(box.element, { animation: 150, onEnd: dropped });
    box.motif.setDisposable(() => sortable.destroy());
  };

  return <div onBuilt={setup}>{props.childs}</div>;
}
import { motifComponent, read, type Bind, type Component, type ComponentBase } from "@motifx/core";
import Sortable from "sortablejs";

export function SortableList<T>(props: { list: Bind<T[]>; childs?: ComponentBase[] }) {
  const dropped = (evt: Sortable.SortableEvent) => {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  };

  return motifComponent("div", {
    initializeComponent: (box: ComponentBase) => box.controls.add(...(props.childs ?? [])),
    onBuilt: (box: Component<HTMLDivElement>) => {
      const sortable = Sortable.create(box.element, { animation: 150, onEnd: dropped });
      box.motif.setDisposable(() => sortable.destroy());
    },
  });
}
import { motifOptions, read, type Bind } from "@motifx/core";
import Sortable from "sortablejs";

export const SortableList = <T>(props: { list: Bind<T[]> }) => motifOptions({
  el: "div",

  onBuilt() {
    const sortable = Sortable.create(this.element as HTMLElement, { animation: 150, onEnd: (evt) => this.dropped(evt) });
    this.motif.setDisposable(() => sortable.destroy());
  },

  dropped(evt: Sortable.SortableEvent) {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  },
});
import { Component, read } from "@motifx/core";
import Sortable from "sortablejs";

export class SortableList extends Component {
  static elementTag = "div";

  onBuilt() {
    const sortable = Sortable.create(this.element, { animation: 150, onEnd: (evt) => this.dropped(evt) });
    this.motif.setDisposable(() => sortable.destroy());
  }

  dropped(evt) {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(this.props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  }
}
import { read } from "@motifx/core";
import Sortable from "sortablejs";

export function SortableList(props) {
  const dropped = (evt) => {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  };

  const setup = (box) => {
    const sortable = Sortable.create(box.element, { animation: 150, onEnd: dropped });
    box.motif.setDisposable(() => sortable.destroy());
  };

  return <div onBuilt={setup}>{props.childs}</div>;
}
import { motifComponent, read } from "@motifx/core";
import Sortable from "sortablejs";

export function SortableList(props) {
  const dropped = (evt) => {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  };

  return motifComponent("div", {
    initializeComponent: (box) => box.controls.add(...(props.childs ?? [])),
    onBuilt: (box) => {
      const sortable = Sortable.create(box.element, { animation: 150, onEnd: dropped });
      box.motif.setDisposable(() => sortable.destroy());
    },
  });
}
import { motifOptions, read } from "@motifx/core";
import Sortable from "sortablejs";

export const SortableList = (props) => motifOptions({
  el: "div",

  onBuilt() {
    const sortable = Sortable.create(this.element, { animation: 150, onEnd: (evt) => this.dropped(evt) });
    this.motif.setDisposable(() => sortable.destroy());
  },

  dropped(evt) {
    const { item, from, oldIndex, newIndex } = evt;
    if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;

    // 1. Undo SortableJS's move
    item.remove();
    const last = from.lastElementChild;
    const ref = from.children[oldIndex] ?? (last ? last.nextSibling : from.lastChild);
    from.insertBefore(item, ref);

    // 2. Apply the same move to the model; 3. the list binding reorders the DOM
    const list = read(props.list);
    const [moved] = list.splice(oldIndex, 1);
    list.splice(newIndex, 0, moved);
  },
});

In use, list is given the very reactive array whose rows are drawn, and every row carries a key. In the example the array being sorted is in a local reactive object that keeps the order of the folders in the sidebar: const order = reactive({ folders: ["notes", "pinned", "archive"] as Folder[] }).

<SortableList list={order.folders}>
  {order.folders.map((folder) => <div key={folder} class="row">{FOLDERS[folder].title}</div>)}
</SortableList>
const rows = motifComponent({
  initializeComponent: (holder: ComponentBase) =>
    holder.bindings.list(() => order.folders, (folder: Folder) => motifComponent("div", {
      indexkey: () => folder,
      initializeComponent: (row: ComponentBase) => {
        row.class.add("row");
        row.setText(FOLDERS[folder].title);
      },
    })),
});
const folderList = new SortableList({ list: order.folders, childs: [rows] });
const rows = motifComponent({
  initializeComponent: (holder: ComponentBase) =>
    holder.bindings.list(() => order.folders, (folder: Folder) => motifComponent("div", {
      indexkey: () => folder,
      initializeComponent: (row: ComponentBase) => {
        row.class.add("row");
        row.setText(FOLDERS[folder].title);
      },
    })),
});
const folderList = motifComponent(SortableList, { list: order.folders, childs: [rows] });
<SortableList list={order.folders}>
  {order.folders.map((folder) => <div key={folder} class="row">{FOLDERS[folder].title}</div>)}
</SortableList>
const rows = motifComponent({
  initializeComponent: (holder) =>
    holder.bindings.list(() => order.folders, (folder) => motifComponent("div", {
      indexkey: () => folder,
      initializeComponent: (row) => {
        row.class.add("row");
        row.setText(FOLDERS[folder].title);
      },
    })),
});
const folderList = new SortableList({ list: order.folders, childs: [rows] });
const rows = motifComponent({
  initializeComponent: (holder) =>
    holder.bindings.list(() => order.folders, (folder) => motifComponent("div", {
      indexkey: () => folder,
      initializeComponent: (row) => {
        row.class.add("row");
        row.setText(FOLDERS[folder].title);
      },
    })),
});
const folderList = motifComponent(SortableList, { list: order.folders, childs: [rows] });

The ref calculation in the undo is deliberately not from.children[oldIndex] ?? null. MotifJS draws the list inside a fragment; the container's last child is usually a comment node that is the list's closing marker. If the item was dragged from the end, insertBefore(item, null) puts it outside that marker, that is, outside the list, and the list binding cannot find the item on the next update. The nextSibling of the last element, on the other hand, gives the spot in front of the marker, and the item stays inside the list.

This simple form is for a single list. If the library manages the inside of its own container completely (a chart, an editor), there is no clash: the component only provides an empty container, and MotifJS draws nothing inside it.

Root element

Most libraries want a real element (canvas, div).

In a class, the root element is decided not by view() but by the class itself. When you write Component<HTMLCanvasElement> in TypeScript, @motifx/compiler takes the element type from the generic, and the root is created as a <canvas> on its own. In JavaScript, and in a file that does not go through the compiler, write static elementTag = "canvas". A tag name or a ready element can also be given as the first parameter of the super call in the constructor: super("canvas", props), super(document.createElement("canvas"), props); this form does not depend on the generic, so it works in a package not compiled with @motifx/compiler as well.

In a function component, the root is the returned element: <canvas … /> in JSX, and motifComponent("canvas", …) without JSX.

In Options, the root is the el field: el: "canvas".

If no root element is declared, the root is a fragment (a comment node), and the content takes the place of this fragment. A fragment is not a real element, so it cannot be given to a library as a container; always declare the root element in a wrapper.

In a component that does not write view(), the children are added inside the root element: in a class if the root is declared with the generic or static elementTag, and in Options with el. That is why SortableList keeps its rows inside its own element. In a class that builds its root with super("div", props), you place the children yourself.

Careful with prop names

The options prop belongs to the framework: it is the component's own settings object, read in the constructor, and the recognized keys are copied into motif.options: hideStrategy, disableDisposal (see Hide strategy) and isSvg, which creates the root element in the SVG namespace. Library options are not passed with this prop; pick an explicit name such as chartOptions or sortableOptions:

<FolderChart chartOptions={{ responsive: true, animation: false }} />
const chart = new FolderChart({ chartOptions: { responsive: true, animation: false } });
const chart = motifComponent(FolderChart, { chartOptions: { responsive: true, animation: false } });
<FolderChart chartOptions={{ responsive: true, animation: false }} />
const chart = new FolderChart({ chartOptions: { responsive: true, animation: false } });
const chart = motifComponent(FolderChart, { chartOptions: { responsive: true, animation: false } });

The component reads this value from its props during setup and hands it to Chart.js: new Chart(canvas, { type: "bar", data, options: chartOptions }).

Props starting with on stay in the props and can be used as callbacks; those that carry a DOM event name are the exception. A prop such as onChange, onCopy, onResize, onSelect, onReset or onContextMenu on a component tag is attached to the root element as a DOM listener and never reaches the props; for the camelCase spelling the compiler prints an MJX002 warning. Make the name specific to the domain (onChartUpdate, onCodeCopy).

The single-copy rule

If the wrapper is a separate package, the wrapped library and @motifx/core must be a single copy in the application. If there are two copies:

Both errors are silent. In Vite the fix is a single line:

resolve: { dedupe: ['@motifx/core', 'chart.js', 'sortablejs'] }
resolve: { dedupe: ['@motifx/core', 'chart.js', 'sortablejs'] }

Prove the cleanup

A wrapper's correctness is not measured by "it seems to work". Write a round-trip test that builds and tears down the component many times: at the end of the rounds, the number of live components and the number of live external instances should return to where they started. For the measurement hooks, see the disposableTracker section in Memory Management and Disposal.

Next step

To publish the wrapper or a set of components on npm, go on to Publishing an npm Library for MotifJS.