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