Services
A service is a shared source of props that components subscribe to. It is lazy and reference-counted: the source starts on the first subscriber and stops on the last, so with no subscriber there is no listener, no observer and no frame.
The sources
| Service | Hook | Props |
|---|---|---|
useRaf() | ticked | time, delta |
useScroll(target?) | scrolled | x, y, deltaX/Y, maxX/Y, progressX/Y, directionX/Y, isScrolling |
useResize(target?) | resized | width, height, ratio, orientation |
usePointer(target?) | moved | event, isDown, x, y, deltaX/Y, maxX/Y, progressX/Y |
useDrag(target, options?) | dragged | mode, x, y, deltaX/Y, originX/Y, distanceX/Y, finalX/Y |
useKey(target?) | keyed | event, triggered, isDown, isUp, plus one boolean per named key |
useInView(target, init?) | intersected | isInView, entry |
useMutation(target, init?) | mutated | records |
useScrollProgress(target, options?) | scrolledInView | startX/Y, endX/Y, currentX/Y, progressX/Y |
useBreakpoint() | — | name |
useMediaQuery(query) | — | matches |
useWindowScroll() and useWindowSize() name the default cases. usePrefersReducedMotion() is the named media query.
Subscribing by hand
Every service has the same two-method surface, so subscribing is one line and the release is what mounted() returns:
import { Base, useScroll } from '@studiometa/js-toolkit';
class Header extends Base {
static config = { name: 'Header' };
mounted() {
return useScroll().subscribe(({ directionY, y }) => {
this.$el.classList.toggle('is-hidden', directionY > 0 && y > 100);
});
}
}subscribe(callback, options?) returns the unsubscribe function. props() reads the current props with no subscription.
Asking for the first delivery
useScroll().subscribe(callback, { immediate: true });The sources that have a current value honour it; the ones that do not, do nothing. The frame tick has no current value between two frames, the pointer has none before it is seen, and a drag has none outside a gesture. Only the new subscriber is called, and the first props of a run carry no movement.
Mixins — the declarative form
A mixin binds one subscription per mount cycle, under the one method name the service owns:
import { Base, withScroll } from '@studiometa/js-toolkit';
class Header extends withScroll(Base) {
static config = { name: 'Header' };
scrolled({ directionY }) {
this.$el.classList.toggle('is-hidden', directionY > 0);
}
}The mixins are withRaf, withScroll, withResize, withScrollProgress, withPointer, withDrag, withInView, withMutation and withKey.
A mixin never occupies a lifecycle hook
mounted() and unmounted() belong to the component author. The subscription rides on the framework's own $mount()/$unmount() pair, so a class that mixes a service in and writes its own mounted() without super.mounted() still subscribes.
The subscription therefore starts once the whole of mounted() has run — including an immediate first delivery, which reaches a component that is fully set up — and is released before unmounted().
Scoping a mixin to a ref
import { Base, withResize } from '@studiometa/js-toolkit';
class Panel extends withResize(Base, { target: (instance) => instance.$refs.inner }) {
static config = { name: 'Panel' };
resized({ width }) {
console.log(width);
}
}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.
Suspending a hook
{ manual: true } declares the hook without running it. $services.<hook> is the switch:
import { Base, withRaf } from '@studiometa/js-toolkit';
class SliderItem extends withRaf(Base, { manual: true }) {
static config = { name: 'SliderItem' };
ticked({ delta }) {
// declared, not running
}
start() {
this.$services.ticked.start();
}
stop() {
this.$services.ticked.stop();
}
}One hook per class, and that is the limit
A mixin binds one subscription, under one name, per mount cycle. A component whose subscriptions are one per markup declaration — an attribute-driven set, 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 and returns the release from mounted(). That is the intended path, not a workaround.
on<Event> has the same shape: a handler name belongs to the class, while a set of events can be data.
Two combinators
toggle() — a subscription you can switch
import { Base, toggle, useRaf } from '@studiometa/js-toolkit';
class SliderItem extends Base {
static config = { name: 'SliderItem' };
#frame = toggle(() => useRaf().subscribe(({ delta }) => this.follow(delta)));
mounted() {
// `stop` is bound, so it is a cleanup as it is.
return this.#frame.stop;
}
follow(delta) {}
onSelected() {
this.#frame.start();
}
onSettled() {
this.#frame.stop();
}
}start() is idempotent and stop() is safe to repeat. It works on a Signal, on a bare listener, and outside a component.
until() — a one-shot wait
import { until, useScroll } from '@studiometa/js-toolkit';
async function afterScroll() {
const props = await until(useScroll(), ({ isScrolling }) => !isScrolling);
console.log(props.y);
}It resolves on the first update that matches, releases the subscription before it resolves, and resolves with a copy of the props. It resolves at once when the current props already match.
The props contract
- Props are flat, one field per axis, and nothing derivable is a field.
lastXisx - deltaX;changedXisdeltaX !== 0. The grouped objects of v3 (last,delta,max,progress,direction,changed) are gone. directionXanddirectionYare-1 | 0 | 1— one signed value that multiplies.- Every field is
readonly, and the props object belongs to its service. It is valid for the duration of the call that received it. Use{ ...props }to keep one.
One instance per target and per options
Services are keyed in a WeakMap by target and by the meaning of their options, so two callers asking for the same thing share one source:
import { useInView } from '@studiometa/js-toolkit';
const el = document.body;
// The same service — the key is read by meaning, not by spelling.
useInView(el, { threshold: 0.5, rootMargin: '0px' });
useInView(el, { rootMargin: '0px', threshold: 0.5 });Nothing groups observers across targets.
No service owns a loop
The raf service and an active drag inertia subscribe to scheduler.tick(). The scroll service coalesces its events into one read per frame. The resize service is a ResizeObserver. See The scheduler.
Writing your own
createService() is the whole primitive. Give it a props reader and a start function that returns its teardown:
import { createService, perTarget } from '@studiometa/js-toolkit';
interface FocusProps {
readonly hasFocus: boolean;
}
const useFocus = perTarget((target: Element) =>
createService<FocusProps>({
props: () => ({ hasFocus: target.contains(document.activeElement) }),
start(emit) {
const onChange = () => emit({ hasFocus: target.contains(document.activeElement) });
document.addEventListener('focusin', onChange);
document.addEventListener('focusout', onChange);
return () => {
document.removeEventListener('focusin', onChange);
document.removeEventListener('focusout', onChange);
};
},
}),
);perTarget() gives it the one-instance-per-target caching every built-in service has. See createService() and createServiceMixin().