Skip to content

Services hooks

A service mixin binds one subscription per mount cycle, under the one method name the service owns. There is no hook option.

MixinHookProps type
withRaftickedRafProps
withScrollscrolledScrollProps
withResizeresizedResizeProps
withPointermovedPointerProps
withDragdraggedDragProps
withKeykeyedKeyProps
withInViewintersectedInViewProps
withMutationmutatedMutationProps
withScrollProgressscrolledInViewScrollProgressProps

Usage

js
import { 
Base
,
withScroll
} from '@studiometa/js-toolkit';
class
Header
extends
withScroll
(
Base
) {
static
config
= {
name
: 'Header' };
scrolled
({
y
,
directionY
,
isScrolling
}) {
this.
$el
.
classList
.
toggle
('is-hidden',
directionY
> 0 &&
y
> 100);
} }

Mixins stack, and their $services keys accumulate:

js
import { 
Base
,
withRaf
,
withResize
} from '@studiometa/js-toolkit';
class
Parallax
extends
withRaf
(
withResize
(
Base
)) {
static
config
= {
name
: 'Parallax' };
#height = 0;
resized
({
height
}) {
this.#height =
height
;
}
ticked
({
delta
}) {
// … } }

A mixin 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 consequences are worth knowing:

  • 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 about 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().

Mixin 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 underlying use*() call and they are absent from its options type.

A service's own options are passed in the same object and forwarded:

js
class Slide extends withDrag(Base, { axis: 'x', inertia: false, immediate: true }) {}

target

js
withResize(Base, { target: (instance) => instance.$refs.inner });

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.

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. That is why core checks the result at runtime.

manual

Declares the hook without running it. $services.<hook> is 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
();
} }

$services is declared in the type as ServiceHandles<'ticked'>, so the key completes and a wrong name is a type error. Each handle is a Toggle.

immediate

Asks for the first delivery at subscribe time. The sources with a current value honour it; the ones without do nothing. withInView defaults it to true.

ticked can return a render function

withRaf is the one hook whose return value is used: return a function and it runs in the write phase of the same frame.

js
import { 
Base
,
withRaf
} from '@studiometa/js-toolkit';
class
Follower
extends
withRaf
(
Base
) {
static
config
= {
name
: 'Follower' };
ticked
({
delta
}) {
const
x
= this.
measure
(
delta
);
return () => { this.
$el
.
style
.
transform
= `translateX(${
x
}px)`;
}; }
measure
(
delta
) {
return
delta
;
} }

The raf service collects the render functions of its callbacks and cancels a render whose subscriber left between the two phases.

One hook per class, and that is the limit

A mixin binds one subscription, under one name, for each mount cycle. 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 calls subscribe() 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. Neither limit is about a build step — withRaf(Base) is an ordinary call, and the name is fixed by how the class is written.

Failures

A subscriber that throws is skipped and reported as callback.service-failed. Core dispatches the diagnostic first and calls reportError() only when no listener cancelled the event.

MIT Licensed