Refs
A ref is an element of a component's own markup, named in the HTML with data-ref and declared in config.refs.
Refs are live. Each $refs property reads the DOM on access, so markup put into a component is found with no refresh, and no detached element stays in a list. There is no $update().
A single ref
<div data-component="Dialog">
<button data-ref="close">Close</button>
</div>import { Base } from '@studiometa/js-toolkit';
class Dialog extends Base {
static config = {
name: 'Dialog',
refs: ['close'],
};
mounted() {
this.$refs.close.focus();
}
onCloseClick() {
this.$el.removeAttribute('data-option-open');
}
}A plain declaration selects [data-ref="close"] and gives the first match.
A list of refs
A list ref keeps the [] in the attribute. The suffix is part of the name, in the config and in the markup:
<ul data-component="Tabs">
<li><button data-ref="tabs[]">One</button></li>
<li><button data-ref="tabs[]">Two</button></li>
</ul>import { Base } from '@studiometa/js-toolkit';
class Tabs extends Base {
static config = {
name: 'Tabs',
refs: ['tabs[]'],
};
onTabsClick({ index }) {
console.log(`tab ${index} was clicked`);
}
}$refs.tabs is an array. The property name never carries the suffix — tabs[] in the declaration, $refs.tabs everywhere else.
The two spellings must agree
config.refs: ['tabs[]'] matches data-ref="tabs[]" and nothing else. The opposite mistake — the suffix missing from the attribute — gives one ref.mismatch warning per instance and per ref, naming the component and both spellings.
Ref boundaries
By default a ref belongs to the nearest enclosing component. A ref inside a nested component is that component's, not yours:
<div data-component="Slider">
<button data-ref="next">Slider's own</button>
<div data-component="SliderItem">
<button data-ref="next">SliderItem's, not Slider's</button>
</div>
</div>Naming the owner
A ref can name the component it belongs to, and then it crosses boundaries:
<div data-component="Slider">
<div data-component="SliderItem">
<button data-ref="Slider.next">Slider's, from inside a child</button>
</div>
</div>Slider.next passes every boundary except another Slider, so the nearest Slider wins and a nested Slider shadows its parent.
The namespace is written in the markup only, never in config.refs, and the name elsewhere never carries it:
| markup | config.refs | property | handler | decorator |
|---|---|---|---|---|
data-ref="next" | 'next' | $refs.next | onNextClick | @on('next', …) |
data-ref="Slider.next" | 'next' | $refs.next | onNextClick | @on('next', …) |
data-ref="dots[]" | 'dots[]' | $refs.dots | onDotsClick | @on('dots[]', …) |
data-ref="Slider.dots[]" | 'dots[]' | $refs.dots | onDotsClick | @on('dots[]', …) |
The namespace goes before the suffix: Component.name[].
Event handlers
on<Ref><Event> handlers are delegated from the root element, so a ref that appears later needs no new binding:
import { Base } from '@studiometa/js-toolkit';
class Todo extends Base {
static config = {
name: 'Todo',
refs: ['items[]', 'input'],
};
// Fires for any `items[]` ref, including the ones added after mount.
onItemsClick({ event, target, index }) {
console.log(index, target);
}
onInputInput({ target }) {
console.log(target.value);
}
}The payload is { event, target, index }:
event— the DOM event.target— the ref element the handler matched, notevent.target.index— the position in the list, or0for a single ref.
Events that do not bubble — focus, blur, scroll, mouseenter, mouseleave — are delegated from the capture phase, so they are heard all the same.
In TypeScript, annotate the payload with RefEvent:
import { Base, type RefEvent } from '@studiometa/js-toolkit';
class Todo extends Base {
static config = { name: 'Todo', refs: ['input'] };
onInputInput({ target }: RefEvent<HTMLInputElement>) {
console.log(target.value);
}
}How the live read works
Ref lookups are cached, and the cache is invalidated by a counter that the framework's own MutationObserver increases. Reading that counter drains the pending records with takeRecords(), so the read is current even inside the same task as the mutation. Detached elements are never cached.
The practical consequence: you never refresh anything.
import { Base } from '@studiometa/js-toolkit';
class Todo extends Base {
static config = { name: 'Todo', refs: ['list', 'items[]'] };
add(title) {
const li = document.createElement('li');
li.dataset.ref = 'items[]';
li.textContent = title;
this.$refs.list.append(li);
// Already there. No `$update()`, no re-query.
console.log(this.$refs.items.length);
}
}