Service mixins
A mixin binds one subscription per mount cycle, under the one method name the service owns.
| Mixin | Hook | Service |
|---|---|---|
withRaf | ticked | useRaf() |
withScroll | scrolled | useScroll() |
withResize | resized | useResize() |
withPointer | moved | usePointer() |
withDrag | dragged | useDrag() |
withKey | keyed | useKey() |
withInView | intersected | useInView() |
withMutation | mutated | useMutation() |
withScrollProgress | scrolledInView | useScrollProgress() |
Two call forms
withScroll(BaseClass, options?) // a mixin
withScroll(options?) // a class decoratorimport { Base, withScroll } from '@studiometa/js-toolkit';
class Header extends withScroll(Base) {
static config = { name: 'Header' };
scrolled({ y, directionY }) {
this.$el.classList.toggle('is-hidden', directionY > 0 && y > 100);
}
}import { Base, component, withScroll } from '@studiometa/js-toolkit';
@component({ name: 'Header' })
@withScroll()
class Header extends Base {
scrolled() {}
}The mixin is the primitive, because it needs no build step. The decorator is sugar over it.
Stacking
import { Base, withRaf, withResize } from '@studiometa/js-toolkit';
class Parallax extends withRaf(withResize(Base)) {
static config = { name: 'Parallax' };
resized({ height }) {}
ticked({ delta }) {}
}$services keys accumulate through the intersection, so $services.ticked and $services.resized both complete.
Options
interface ServiceMixinOptions<Target, Host = Base> {
target?: (instance: Host) => Target;
manual?: boolean;
immediate?: boolean;
}The options of a mixin are not the options of the service. target, manual and immediate describe the subscription; they are removed before the use*() call and they are absent from its options type. A service's own options go in the same object and are forwarded:
withDrag(Base, { axis: 'x', inertia: false, immediate: true, manual: true });target
withScroll(Base, { target: (instance) => instance.$refs.scroller });A resolver that comes back with undefined or null reports service.missing-target and starts no subscription — because a renamed ref arrives here as nothing, and a service with a default target would otherwise observe the wrong thing and look like it worked.
A service whose own default target is nothing, like withRaf, is untouched: that is its contract, and only a caller's resolver can be wrong about it.
The resolver is typed against the host as declared
A mixin is applied while the extends clause of its class is still being evaluated, so withDrag(Base, …) types its resolver against Base. A component reaching further names the shape it needs — (instance as Base & { readonly target: HTMLElement }).target — which is an assertion rather than a check. v3 wrote the same line as an @ts-expect-error. That is why core checks the result at runtime.
manual
Declares the hook without running it, and $services.<hook> becomes the switch:
import { Base, withRaf } from '@studiometa/js-toolkit';
class SliderItem extends withRaf(Base, { manual: true }) {
static config = { name: 'SliderItem' };
ticked({ delta }) {}
onSelected() {
this.$services.ticked.start();
}
onSettled() {
this.$services.ticked.stop();
}
}Each handle is a Toggle: start() is idempotent and stop() is safe to repeat.
immediate
Asks for the first delivery at subscribe time. withInView defaults it to true.
It never occupies a lifecycle hook
mounted() and unmounted() belong to the component author, so nothing has to be chained. A class that mixes a service in and writes its own mounted() without super.mounted() still subscribes: the framework's own $mount()/$unmount() pair carries the subscription.
- The subscription starts once the whole of
mounted()has run — including animmediatefirst delivery, which therefore reaches a component that is fully set up. - It is released before
unmounted(), exactly where the mount cleanup used to release it. $unmount()releases unconditionally, so a manual subscription started outside a mount cycle is released too.
A userland mixin still chains
The rule is about what the mixin overrides, not who wrote it. A mixin that puts its work in mounted() needs its subclasses to call super.mounted() — and the way not to need that is to override $mount(). See createServiceMixin().
One hook per class, and that is the limit
A mixin binds one subscription, under one name, for each mount cycle. There is no hook option.
A component whose subscriptions are one per markup declaration — one per attribute, with its own modifiers and its own threshold, known only when the element is read — has no method to name and no fixed count. It subscribes itself:
mounted() {
return useScroll(this.$refs.panel).subscribe((props) => {});
}on<Event> has the same shape: a handler name belongs to the class, while a set of events can be data. Both sugars are keyed on a name a class declares, and both leave the same escape open — bind it yourself, own the cleanup.
Neither limit is about a build step: withRaf(Base) is an ordinary call, and the name is fixed by how the class is written.