Skip to content

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

ServiceHookProps
useRaf()tickedtime, delta
useScroll(target?)scrolledx, y, deltaX/Y, maxX/Y, progressX/Y, directionX/Y, isScrolling
useResize(target?)resizedwidth, height, ratio, orientation
usePointer(target?)movedevent, isDown, x, y, deltaX/Y, maxX/Y, progressX/Y
useDrag(target, options?)draggedmode, x, y, deltaX/Y, originX/Y, distanceX/Y, finalX/Y
useKey(target?)keyedevent, triggered, isDown, isUp, plus one boolean per named key
useInView(target, init?)intersectedisInView, entry
useMutation(target, init?)mutatedrecords
useScrollProgress(target, options?)scrolledInViewstartX/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:

js
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

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

js
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

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

js
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

js
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

js
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. lastX is x - deltaX; changedX is deltaX !== 0. The grouped objects of v3 (last, delta, max, progress, direction, changed) are gone.
  • directionX and directionY are -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:

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

ts
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().

MIT Licensed