Skip to content

Service mixins

A mixin binds one subscription per mount cycle, under the one method name the service owns.

MixinHookService
withRaftickeduseRaf()
withScrollscrolleduseScroll()
withResizeresizeduseResize()
withPointermovedusePointer()
withDragdraggeduseDrag()
withKeykeyeduseKey()
withInViewintersecteduseInView()
withMutationmutateduseMutation()
withScrollProgressscrolledInViewuseScrollProgress()

Two call forms

ts
withScroll(BaseClass, options?)   // a mixin
withScroll(options?)              // a class decorator
js
import { 
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);
} }
ts
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

js
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

ts
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:

js
withDrag(Base, { axis: 'x', inertia: false, immediate: true, manual: true });

target

js
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:

js
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 an immediate first 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:

js
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.

MIT Licensed