Component Tree
How the tree is built
The component tree in js-toolkit is determined by the DOM structure and the components configuration. When a component mounts, it scans its root element for child components declared in config.components.
class App extends Base {
static config = {
name: 'App',
components: { Header, Sidebar, Content },
};
}<body data-component="App">
<header data-component="Header">
<nav data-component="Nav">...</nav>
</header>
<aside data-component="Sidebar">...</aside>
<main data-component="Content">...</main>
</body>In this example, App is the parent of Header, Sidebar, and Content. But Nav is only a child of Header if Header declares it in its own config.components.
Element resolution
Child components are found using two selector strategies:
- Attribute selector:
[data-component="<KEY>"]— matches elements with the exact component name - Tag selector:
<prefix>-<dash-case-name>— matches custom element tags (e.g.<my-component>)
If no matching element is found, the child is simply not instantiated — no errors, no empty instances.
Multiple instances of the same child component are supported:
<div data-component="List">
<div data-component="ListItem">Item 1</div>
<div data-component="ListItem">Item 2</div>
<div data-component="ListItem">Item 3</div>
</div>Mounting order
Components mount depth-first, children before parents. This guarantees that when a parent's mounted() hook runs, all its children are already mounted.
1. App starts mounting
2. Header starts mounting
3. Nav mounts → Nav.mounted()
4. Header.mounted()
5. Sidebar.mounted()
6. Content.mounted()
7. App.mounted()The framework uses a queue internally to batch DOM reads and writes, avoiding layout thrashing during mount.
Do not assume an ancestor exists while mounting
The children-before-parents guarantee runs one way: it covers the children a parent mounts through its own ChildrenManager (declared in config.components and DOM-contained). Whether the reverse works — a child resolving its parent from its own mounted() — depends on how the child was mounted:
- Mounted by its parent — the ancestor is already constructed and registered (from its
before-mounted, beforemountAll()runs), so$closestand$parentresolve, even though the parent is not yet$isMounted. - Mounted independently — globally registered components (
registerComponentrecursively registers declared children) are auto-mounted by a document-wideMutationObserver, and lazy/async children mount on their own schedule. On that path the ancestor may not exist yet and$closestreturnsundefined.
Application code cannot tell which path mounted a given instance, so never rely on an ancestor in mounted(). Route communication through the channels in Data flow between components.
Scoping
Each component's scope is bounded by its root element (the data-component element). This scoping affects:
- Refs —
this.$refsonly finds refs within the component's own scope, not inside nested child components - Children — only direct children declared in
config.componentsare managed - Events — event handler methods like
onBtnClickonly bind to refs within scope
<div data-component="Parent">
<button data-ref="btn">Parent's button</button>
<div data-component="Child">
<button data-ref="btn">Child's button</button>
</div>
</div>Here, Parent's this.$refs.btn is only the first button. The second button belongs to Child's scope.
Querying components
You can traverse the tree at runtime:
$query(name)— find all descendant instances matching a component name (replaces the deprecated$children)$closest(name)— find the nearest ancestor instance (replaces the deprecated$parent/$root)
class Child extends Base {
static config = { name: 'Child' };
mounted() {
const app = this.$closest('App');
const siblings = app.$query('Child');
}
}API Reference
See Instance methods — $query and Instance methods — $closest.
Data flow between components
A component can't assume its parent exists while it is mounting (see Mounting order). Both ancestor lookups are resolved dynamically — by walking the DOM ancestors on every access — but they differ:
$parent(deprecated) returnsnullunless a live ancestor both declares this component in itsconfig.componentsand DOM-contains it.$closest(name)returns any constructed ancestor with that name — including one not yet, or no longer, mounted (e.g. breakpoint-inactive) — and isundefinedonly when no such ancestor has been constructed. Add the:mountedstate ($closest('Slider:mounted')) to require a mounted one, and always guard with?..
Structure communication through the sanctioned channels below. $closest element-correctness and the store utilities below require @studiometa/js-toolkit >= 3.6.0.
Parent → child: $query
The parent is always mounted when it queries, so reading or commanding children is safe:
class Slider extends Base {
static config = { name: 'Slider', components: { SliderBtn } };
goTo(index) {
this.$query('SliderBtn').forEach((btn) => btn.setActive(index));
}
}Child → parent: $emit
Children never dereference the parent — they emit, and the parent listens via the on<Child> convention:
class SliderBtn extends Base {
// The emitted event name must differ from the DOM event (`click`), otherwise
// `$emit` re-dispatches on `$el` and re-triggers `onClick` in a loop.
static config = { name: 'SliderBtn', emits: ['go-to-index'] };
onClick() {
this.$emit('go-to-index', this.index);
}
}
class Slider extends Base {
static config = { name: 'Slider', components: { SliderBtn } };
// Child-event handlers receive a single params object; `args` holds the payload.
onSliderBtnGoToIndex({ args: [index] }) {
this.goTo(index);
}
}Dynamic ancestor lookup: $closest
When a child genuinely needs the ancestor, resolve it lazily and guard the result with ?. — never at construction, and never unguarded in mounted():
class SliderBtn extends Base {
static config = { name: 'SliderBtn' };
onClick() {
this.$closest('Slider')?.goTo(this.index); // undefined-safe
}
}Shared state: a per-instance store
When both sides read and write the same state (e.g. the active index), lift it into a store and drop the parent⇄child runtime dependency. Give each parent its own store instance so multiple sliders on a page don't share state:
// Slider/Slider.js
import { Base } from '@studiometa/js-toolkit';
import {
createStorage,
createMemoryStorageProvider,
} from '@studiometa/js-toolkit/utils';
import { SliderChild } from './SliderChild.js';
export class Slider extends Base {
static config = { name: 'Slider', components: { SliderChild } };
// One store per Slider instance — the shared `memoryStorageProvider`
// singleton would sync every Slider on the page.
store = createStorage({ provider: createMemoryStorageProvider() });
index = 0;
mounted() {
// Runs after `__children.mountAll()`, so every SliderChild is mounted. The
// parent reaches its children deterministically regardless of how they were
// mounted, so subscribe them from here, then push the current state so every
// subscriber receives its initial value. `$query` matches descendants at any
// depth; with nested Sliders the idempotent `subscribe()` keeps each child on
// the innermost store it already joined.
for (const child of this.$query('SliderChild')) {
child.subscribe(this.store);
}
this.store.set('index', this.index);
}
goTo(index) {
this.index = index;
this.store.set('index', index);
}
}// Slider/SliderChild.js
import { Base } from '@studiometa/js-toolkit';
import { isFunction } from '@studiometa/js-toolkit/utils';
export class SliderChild extends Base {
static config = { name: 'SliderChild' };
index = 0;
// Idempotent: safe to call from the parent's handshake and from a late mount.
subscribe(store) {
if (isFunction(this.unsubscribe)) return;
this.index = store.get('index') ?? this.index;
this.unsubscribe = store.subscribe('index', (index) => {
this.index = index;
});
}
mounted() {
// A child mounted *after* its Slider (e.g. through `$update()`) can resolve
// it here; guard because `$closest` is undefined when it cannot.
const slider = this.$closest('Slider');
if (slider) this.subscribe(slider.store);
}
destroyed() {
if (isFunction(this.unsubscribe)) this.unsubscribe();
// Clear it so a destroy → remount cycle can subscribe again.
this.unsubscribe = undefined;
}
}Two-sided handshake
Whether a SliderChild can resolve its Slider from its own mounted() depends on the mount path (see Mounting order): when the Slider mounts its children, the ancestor is already resolvable; but a child auto-mounted independently (global registry, lazy import) can run mounted() before its Slider exists, where $closest('Slider') is undefined. A child that only subscribed through a guarded $closest('Slider')?.store.subscribe(...) would then be silently never subscribed and would never sync, with no error.
The handshake closes the gap from both sides: the child subscribes itself whenever it can reach the Slider, and the Slider subscribes every child from its own mounted(), which runs after __children.mountAll() and reaches them deterministically before pushing the current state. Making subscribe() idempotent lets both paths run without double-subscribing.
For state shared across unrelated components or persisted across reloads, use a module-level store with createLocalStorage/createSessionStorage instead of a per-instance memory provider.
Dynamic updates
When the DOM changes (elements added or removed), call $update() on the parent to re-evaluate the tree:
this.$el.insertAdjacentHTML(
'beforeend',
'<div data-component="Child"></div>',
);
this.$update(); // Re-scans children, mounts new onesIf you use registerComponent, a global MutationObserver handles this automatically for top-level registered components.