Shared state
Two components that must agree on something share it through provide/inject — the shape of Vue's provide/inject, with the mechanics of the WICG context protocol.
A typed key
A key is a value, not a string, so keys cannot collide:
import { createContext, type Signal } from '@studiometa/js-toolkit';
interface SliderApi {
state: Signal<{ index: number; total: number }>;
goNext(): void;
}
export const SliderContext = createContext<SliderApi>('slider');The description is for debugging only. The identity of the key is what resolves.
Providing
The coordinator provides what a control can ask for. The value is provided as it is — nothing is wrapped, so the type of the key is the contract from end to end:
class Slider extends Base {
static config = { name: 'Slider' };
api = this.$provide(SliderContext, {
state: signal({ index: 0, total: 0 }), // what changes
goNext: () => this.goNext(), // what a control can command
});
goNext() {
this.api.state.value = {
...this.api.state.value,
index: this.api.state.value.index + 1,
};
}
}- The scope is the subtree, and the nearest provider wins.
- A reactive value is a provided
Signal. A command surface is a provided object. $provide()in a field initializer is instance-scoped: it is never released and dies with the element. A component whose declaration is withdrawn keeps providing until its element goes.
Injecting
| Form | Resolves | When nothing provides |
|---|---|---|
$inject(key) | a promise, awaited in mounted() | it never settles: a missing provider means "not yet". |
$injectSync(key) | the value, synchronously | undefined: the caller falls back or does nothing. |
class SliderNext extends Base {
static config = { name: 'SliderNext' };
async mounted() {
const api = await this.$inject(SliderContext);
return api.state.subscribe(({ index, total }) => {
this.$el.toggleAttribute('disabled', index >= total - 1);
});
}
}The pending request of the async form is unmount-scoped. A new mount runs mounted() again and asks again.
Which form to reach for
$inject() from mounted() is the default: it waits, so mount order does not matter. $injectSync() is for the case where the answer is optional and the caller has a fallback. The @inject field decorator asks once, at construction.
Page-wide state
provideRootContext(key, create) makes the page-wide case the outermost scope of the same mechanism. The value is provided on document.documentElement, so a request from anywhere reaches it by bubbling — and a nearer provider still wins:
// Scoped or page-wide, resolved the same way, nearest first.
const channels =
injectContextSync(el, DataChannels) ?? provideRootContext(DataChannels, () => new Map());create runs at most once per key, and nothing is created at import time. A root provider cannot be disposed and it outlives the instance that asked first, because it is page state.
withGroup is not ported.
Signal
signal(initialValue) is a factory over a closure. The accessor is .value:
import { signal } from '@studiometa/js-toolkit';
const count = signal(0);
const unsubscribe = count.subscribe((value) => console.log(value), { immediate: true });
count.value = 1;
count.value += 1;
unsubscribe();A write settles synchronously and the newest value wins. The delivery loop re-reads the value after each callback; if the value moved, the loop abandons the round and starts again on the new value, so a subscriber not reached yet skips the old value. Delivery stays in the same task.
A subscriber that writes on every delivery live-locks the loop
Guard the write, or move it out of the subscriber.
Reacting to a provider that appears later
subscribeContext(el, key, onProvide) is the subscription behaviour of the WICG protocol. The callback runs synchronously for each answer and receives the value and the same unsubscribe function the helper returns:
class Disclosure extends Base {
static config = { name: 'Disclosure' };
group?: GroupApi;
mounted() {
return subscribeContext(this.$el, DisclosureGroupContext, (group) => {
this.group = group;
const leave = group.join(this);
// The teardown for *this* value.
return () => {
leave();
this.group = undefined;
};
});
}
}- The trigger is the mount announcement, not a broadcast from the provider, and it runs after
mounted(). - A new answer replaces; it never accumulates. The teardown runs before the next different value and on unsubscribe. An identical value is not an answer.
- The registry holds nothing. A subscription is anchored on its consumer element through a
WeakMap, and the iterable index holdsWeakRefs that the sweep prunes. - Callback and teardown failures are isolated, so one consumer cannot stop the shared sweep.
- Two
contains()calls bound the cost per mount. A mount that changes nothing checks nothing.
Groups of peers
createGroup() holds a Set and a Signal. It names no group and resolves no scope — the coordinator owns the membership and gives out the ways in:
class DisclosureGroup extends Base {
static config = { name: 'DisclosureGroup' };
#peers = createGroup<Base>();
api = this.$provide(DisclosureGroupContext, {
members: this.#peers.members, // the members to read, in document order
join: (peer: Base) => this.#peers.join(peer), // returns the leave function
open: (peer: Base) => this.open(peer), // the invariant stays here
});
open(peer: Base) {}
}join()returns its ownleave, so a member that moves to a nearer group leaves the old one first.- Scope comes from nearest-provider-wins, so a nested group takes its own members only.
- The membership is a value. A coordinator subscribes to it and re-checks its invariant on each change.
- Document order is the tie-breaker, so the markup decides which peer keeps its state.
- Nothing sweeps disconnected members. The teardown of the member removes it.
How the mechanics work
The consumer dispatches a bubbling, module-private js-toolkit:context:request event carrying a key, a callback and a subscription marker. It is deliberately not part of public EVENTS. The nearest mounted provider answers and stops propagation. provideContext() replays the requests that have no first answer yet, which is what makes mount order irrelevant. injectContext() and $inject() are one-shot.
Outside a component the same three functions work on a bare element:
const { value, dispose } = provideContext(el, Key, 42);
const { promise, cancel } = injectContext(el, Key);
const maybe = injectContextSync(el, Key);