Guides
Writing a Component Library
Rules for a component library that applications use; props tracked by a single binding, the developer's data used as it is, nothing tracked inside the component, parts drawn by hand, writing only on change, a temporary attribute on the developer's component, the component an event came from, lifetime traps and measuring.
A component library (controls, a data grid, a scheduler) is used in applications it does not know, and often with thousands of its parts on a single page. Application code can lean on reactivity; library code should not. The rules on this page were worked out by writing a complete kit and measuring it. Read the Creating Components and bindings pages first.
Three ways in
A library component listens to only three things:
- Its props: each one a value or a function (
Bind<T>). - The developer's data: used as it is. The same objects are kept and handed back; they are not wrapped with
reactive(), copied or transformed. A change made in place is read again withreState()or an explicitrefresh(). - The developer's
reState()call.
Everything else is the component's own state, and the component draws it by hand.
Props: a value or a function, tracked by a single binding
- Every prop and every property setter takes a value or a function. A function is tracked by a single binding of the component. This binding is registered with
bindings.add(binding)(theIBaseBindingshape, whose type is imported from@motifx/core; see Binding a component property). Since it is in the component's binding registry, the developer'sreState()call reaches it; plain data works as well as reactive data. - The value is passed on if it differs from the last one read (
Object.is). AreState()that finds the same value does not spoil what the user did in the meantime (the text they typed, the day they moved to). - A property assigned from code stops tracking its function.
- To draw a property, a list of appliers is used, not an effect: the value is applied now and on every change, without tracking. No effect is created at all for a property given as a plain value.
- Data objects whose fields can be functions (menu items, toolbar items, summary items): all the fields of a source are read by a single binding, the values read are stored, and drawing is done from them. The developer's function is not called again while drawing; a function that returns a new node would produce a new node on every draw.
- A value that the user changes too (a text box, a list, a checkbox) is given to the control only when its source changes, not on every draw; otherwise what the user typed gets overwritten:
const handed = new WeakMap<object, { value: unknown }>();
export function handOver<V>(control: { value: V }, value: V) {
const last = handed.get(control);
if (last && Object.is(last.value, value)) return;
handed.set(control, { value });
control.value = value;
}
Nothing is tracked inside
- A change is announced where it is made. Every piece of the component's state calls
tell(name). The names are collected and announced once per round; every view redraws the parts that the announced names concern. Nothing watches the state. - The component's response to a change runs before the views. Reloading, re-laying out and closing an editor run first; what they announce joins the same round, and the views draw once.
- Such a response runs when the values it reads differ from the ones it read last.
- Stable values are compared: objects and
Maps rebuilt on every read never come out equal, so they are keyed as JSON. - Counters, such as a "read again" counter, go into the key too.
- Only a real change is announced. A notification announced on every draw is a loop (draw, announce, draw) and can lock up a browser tab.
- Parts drawn by hand are components built in code. JSX is not used there: the compiler turns every expression that reads a variable, even an attribute, into a binding. There is no
bindings.watchor freeeffect()either. On a data grid page:- bindings went down from 1,306 to 87;
- effects running at startup went down from 2,478 to 346;
- a sort now runs no effects at all (140 before).
- Reactive state can stay for the application. An API documented as "reactive" (a total, the number of selected rows) can keep its store for the application's own bindings; the component never reads it with tracking.
- A language or locale change is an event, not a signal. Every control subscribes once and draws its texts and formats now and on every change; the subscription goes away with the control:
export function onLanguage(component: ComponentBase, draw: () => void) {
untracked(draw);
const changed = uiLocale.onChanged(() => !component.isDisposed && untracked(draw));
component.motif.setDisposable(() => changed.dispose());
}
- Content that does not change is built once.
{cond && <X/>}and.maprows given akeyopen bindings per item. Content that does not change can be built once withcontrols.addinsideonConfigorinitializeComponent. On a documentation page, this brought the page's effect count down from 16 thousand to 10 thousand.
The component's tree
view()returns a singleComponentBase. Several parts are put insidemotifFragment();<>…</>compiles to this too.- Parts that have to be direct children of the element (header cells, moved rows) are not returned from
view(); they are added insideinitializeComponent(an override). - Keyed parts are matched by hand. A node that is already in place is never moved: moving the focused element takes away its focus.
controls.remove(part)takes the part out right away.part.dispose()removes its element one round later; a draw made right after that still finds the element.- A part that is shown and hidden in place, like
x-display, swaps places with a comment placeholder and is put back there. The placeholder needs a parent, which is why the top-level parts are drawn for the first time insideonBuilt.
Writing only on change
- Rewriting an attribute, a style or a text even with the same value makes the browser recalculate styles. Hand-drawing code that rewrote unchanged attributes on every draw did five times the style calculation of the reactive code it replaced. A value is written only when it changes.
- Replacing a constructable stylesheet (
replaceSync) with the same text invalidates the styles of the whole document. A grid that rewrote the column stylesheet on every step while a column was being widened spent 60–110 ms per step. When the sheet was written only when its text changed, the long tasks went away.
A temporary attribute on the developer's component
- A library may have to give an attribute, for a while, to a component that was placed inside it and written by the developer:
disabled,tabindex,aria-*.attr.adddoes not fit: on the key it writes, it removes the developer's binding and cannot give the old value back. attr.holdholds the value over the component's own and returns an object that can be disposed:
const held = part.attr.hold({ disabled: true, tabindex: -1, "aria-disabled": "true" });
this.motif.register(held);
held.dispose()returns each key to the component's own value at that moment. The same happens when the component it was given to withmotif.registeris disposed.- While it is held, what the component writes to those keys does not reach the element and waits aside: a new value from a live binding,
attr.add,attr.remove, and a binding that writes the element's property of the same name throughbindings.add.reState(),setState()and moving the component somewhere else do not break the hold. - What comes back is whatever the element had when the hold began; an attribute that was not written with
attr, and an attribute that was not there at all, come back just as they were. If the component wrote to that key during the hold, what it wrote last comes back. - The same key can be held more than once. The element shows the value held last; whichever is disposed first, the component's own value comes back once the last one is disposed too.
attr.holdwrites by the same rule asattr.add: the given key is written on every element. Givingdisabledonly to an element that knows it is the caller's job ("disabled" in part.element). A held value is written once; a function given as the value is not tracked.
The component an event came from
- An event listened to on a containing component comes from one of the components inside it.
motif.isEventFrom(e)tells whether the event started inside a given component:
this.motif.on("click:capture", (_sender, e) => {
if (part.motif.isEventFrom(e)) return;
e.preventDefault();
e.stopImmediatePropagation();
});
- A component without an element answers for its content elements. The answer is the same when asked after the event has finished.
Lifetime
- Disposing a component sets its own fields to
undefined. Callbacks that arrive later (timers, promises, observers, a deferred draw) checkisDisposedfirst. onDisposingruns before the element leaves the DOM, and removing a focused element triggersfocusout. So afocusouthandler can run on a component that is being disposed.- If focus has to move away from a control that is being hidden or deactivated, the next control is focused with
focus({ preventScroll: true }); otherwise the page scrolls to it. refcallbacks run inside the base constructor, before the subclass's field initializers. State arefmay touch is kept not in class fields but in a symbol-keyed object created when needed.
Long work
Promise.thenandqueueMicrotaskdo not let the browser paint; a chain of microtasks is still a single long task. Long work is split into pieces with macrotasks (setTimeout,MessageChannel). Measured:- with microtasks, a single 300 ms long task;
- with
setTimeoutpieces, no long task at all, and 18 painted frames.
Checking the work
- Bindings are counted per component type by walking the component tree (DevTools
inspect(path).bindings); counting only live effects is not enough. A binding that tracks a prop has no effect owner and does not show up in the effect count. - While a view is being rewritten, the old and the new version are compared pixel by pixel on every page of that view. At every step the tests and e2e checks are kept green, and the drop in the effect count is proven.
- An e2e check that reads the page right after
loadwaits for the content first: pages draw their sections afterload.
Next step
To publish the library, go on to the Publishing an npm Library for MotifJS page.