Skip to content

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:

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

ts
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

FormResolvesWhen nothing provides
$inject(key)a promise, awaited in mounted()it never settles: a missing provider means "not yet".
$injectSync(key)the value, synchronouslyundefined: the caller falls back or does nothing.
ts
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:

ts
// 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:

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

ts
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 holds WeakRefs 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:

ts
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 own leave, 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:

ts
const { 
value
,
dispose
} =
provideContext
(
el
,
Key
, 42);
const {
promise
,
cancel
} =
injectContext
(
el
,
Key
);
const
maybe
=
injectContextSync
(
el
,
Key
);

MIT Licensed