Feedback from @decepulis treated as a fast follow/incremental improvement effort for expediency. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
52 KiB
SPF Fundamentals
SPF is a general-purpose composition framework. It provides composable primitives — reactive state, reactors, tasks, and actors — that let you assemble a composition from independent, decoupled behaviors.
The framework doesn't know about your domain. It provides the composition model; you provide the behaviors.
Compositions
A composition is SPF's unit of assembly. createComposition takes a list of behaviors — functions that each handle one concern — wires them to shared reactive channels, and returns a small API for reading state and tearing everything down.
What it is — a factory that wires independent behaviors to shared reactive channels and returns a handle for reading state and tearing everything down.
When to use it — when a problem has multiple concerns that share data and lifecycle. Each concern stays a standalone function; shared values flow through signals; cleanup happens together.
A composition in action
A composition with one stand-in behavior, driven entirely from outside:
import { createComposition, effect, update, computed, type Signal } from '@videojs/spf';
function defineCount({ state }: { state: Signal<{ count?: number }> }) {
// no logic yet — this behavior exists to carry the type
}
const composition = createComposition([defineCount]);
composition.state.get(); // { count?: number }
const stopLogging = effect(() => {
console.log(composition.state.get().count);
});
const doubled = computed(() => (composition.state.get().count ?? 0) * 2);
const id = setInterval(() => {
update(composition.state, { count: (composition.state.get().count ?? 0) + 1 });
}, 250);
await composition.destroy();
stopLogging();
clearInterval(id);
Creating a composition
createComposition takes an array of behaviors and returns a small handle:
const composition = createComposition([defineCount]);
composition.state; // Signal<{ count?: number }>
composition.owners; // Signal<{}>
composition.destroy(); // Promise<void>
Those three properties — state, owners, destroy — are the composition's entire public API.
Note
The set of reactive channels a composition exposes may grow. Additional channels — for example an event stream, either as another TC39 signal or an
EventTarget— are under consideration. Treatstateandownersas the current primitives, not a closed set.
state and owners are TC39 Signals: reactive values that you read with .get() and write with .set(). SPF adds one convenience — update(signal, partial) shallow-merges a partial object into the current value, so behaviors can write one field without spreading the whole object.
Giving state a shape
defineCount is the smallest useful behavior: a function that does nothing except declare the shape of state it expects.
function defineCount({ state }: { state: Signal<{ count?: number }> }) {
// no logic yet — this behavior exists to carry the type
}
A behavior's parameter type is its contract with the composition. Because defineCount annotates its state as Signal<{ count?: number }>, the composition inherits that shape — and anything that tries to misuse it is caught at compile time:
composition.state.get(); // { count?: number }
// @ts-expect-error — count must be a number
composition.state.set({ count: 'not a number' });
Without a behavior, none of that shape exists. createComposition([]) resolves state and owners to Signal<object> — permissive on writes, useless on reads:
const empty = createComposition([]);
empty.state.set({ anythingAtAll: true }); // accepted
empty.state.get().count; // ❌ Property 'count' does not exist on type 'object'
Types come from behaviors.
Note
The exact error messages and inference rules are still evolving. The guarantee that conflicts are caught at compile time is stable; how they surface in your editor is not.
When you pass multiple behaviors, their declarations are combined — incompatible ones fail at compile time. That story is demonstrated in Owners, where multiple behaviors first appear organically.
defineCount is a placeholder. Real behaviors do work — run timers, wire up listeners, manage resources, return cleanup.
Using the composition from outside
From outside the composition — wherever your code called createComposition — you interact with its signals directly. Reading is synchronous:
composition.state.get(); // { count?: number }
Observation uses effect: it runs its callback immediately, tracks every signal the callback reads, and re-runs the callback whenever any of those signals change. It returns a cleanup function.
const stopLogging = effect(() => {
console.log(composition.state.get().count);
});
Derived values use computed: a read-only signal whose value is a function of other signals. It recomputes lazily — only when something reads it after a dependency has changed.
const doubled = computed(() => (composition.state.get().count ?? 0) * 2);
doubled.get(); // whatever `(count ?? 0) * 2` is
Writes from outside are uncommon — ongoing work almost always belongs in a behavior. Driving count on an interval from outside, for example, means you own the interval's lifecycle yourself:
const id = setInterval(() => {
update(composition.state, { count: (composition.state.get().count ?? 0) + 1 });
}, 250);
Destroying the composition runs each behavior's cleanup and awaits any async work — but anything you started out here is on you:
await composition.destroy(); // behaviors are torn down
stopLogging(); // the effect is on you
clearInterval(id); // the interval is on you
State
The setInterval and the logger that drove count from outside both work, but their lifecycles sit apart from the composition's. Moved inside as behaviors, each gets typed access to state, cleanup ties into destroy(), and they coordinate with each other through the shared signal.
State is the surface those behaviors share. A single reactive signal holding a plain object, shared across every behavior in a composition and visible from the outside through composition.state. Behaviors write to it when something happens; behaviors read it to know what's going on; the outside world subscribes when it needs to react. Because it's a signal, changes flow automatically — nobody coordinates, nobody wires things up.
What it is — a reactive signal holding an object, shared across every behavior in a composition.
When to use it — for any value two or more behaviors (or the outside world) need to observe or drive. Counts, selections, flags, timestamps — anything that flows through the composition over time.
A counter behavior
A counter that ticks on an interval paired with a logger that reads the count as it changes — two behaviors coordinating entirely through shared state:
import { createComposition, effect, update, type Signal } from '@videojs/spf';
function counter({
state,
config,
}: {
state: Signal<{ count?: number }>;
config: { interval?: number };
}) {
const id = setInterval(() => {
update(state, { count: (state.get().count ?? 0) + 1 });
}, config.interval ?? 1000);
return () => clearInterval(id);
}
function logCount({ state }: { state: Signal<{ count?: number }> }) {
// effect() returns its own cleanup; handing it back here ties
// the effect's lifecycle to composition.destroy()
return effect(() => {
console.log(state.get().count);
});
}
const composition = createComposition([counter, logCount], {
initialState: { count: 0 },
config: { interval: 250 },
});
// logs: 0, 1, 2, 3, ...
await composition.destroy(); // clears the interval and stops the effect
Neither behavior knows the other exists. counter writes to state; logCount reads it. They coordinate through the shared signal, and each hands back the cleanup that belongs to its own lifecycle — a clearInterval closure for counter, the function effect() returned for logCount. One call to composition.destroy() unwinds both.
initialState
initialState sets the starting value of the state signal:
createComposition([counter], { initialState: { count: 0 } });
composition.state.get(); // { count: 0 }
Its type is derived from the behaviors. Because counter annotates state: Signal<{ count?: number }>, TypeScript requires initialState to be assignable to { count?: number }:
// ✅ matches the behavior's declared state
createComposition([counter], { initialState: { count: 0 } });
// @ts-expect-error — count must be a number
createComposition([counter], { initialState: { count: 'zero' } });
If you omit initialState, the signal starts as {} — which is why counter falls back with state.get().count ?? 0 on its first tick.
config
config is static configuration, passed once at composition time. Unlike state, config never changes and isn't reactive.
createComposition([counter], { config: { interval: 250 } });
Its shape is inferred from the behaviors:
// @ts-expect-error — interval must be a number
createComposition([counter], { config: { interval: 'fast' } });
Behaviors read config directly (config.interval), usually with a fallback. Use config for values a behavior needs to know at construction time and wouldn't expect to change — thresholds, URLs, feature flags. Put values that change over time in state.
Owners
A ticking counter and a console log aren't much of an application. What you'd actually want is to render the count somewhere — say, into an element on the page. That needs access to the element itself: a <div>, a buffer, an open socket. These are resources — platform objects with imperative interfaces that don't fit cleanly into plain application data, and the owners channel is where they live.
The composition holds a signal whose value is a plain object mapping keys to resources. Behaviors read the keys they care about and act on the resources directly; effect() re-runs when a key appears, is replaced, or is cleared. The element itself is still the element — owners don't wrap or proxy it, they just make its lifecycle reactive.
What it is — a reactive signal holding a map of named resources, shared across every behavior in a composition.
When to use it — for values that have identity and behavior, not just data — DOM elements, buffers, long-lived connections. If you'd pass the thing around by reference, it probably belongs in owners.
Note
The name "owners" is provisional. The concept — a channel for mutable resources that behaviors observe and act on — is stable; the label itself may change, and "resources" is one candidate under consideration.
A DOM-renderer behavior
A behavior that renders the counter to a DOM element, joining the counter and logger from the previous section:
import { createComposition, effect, type Signal } from '@videojs/spf';
// counter, logCount — unchanged from the previous section
function renderCount({
state,
owners,
config,
}: {
state: Signal<{ count?: number }>;
owners: Signal<{ renderElement?: HTMLElement }>;
config: { defaultText?: string };
}) {
return effect(() => {
const { renderElement } = owners.get();
if (!renderElement) return;
renderElement.textContent = String(state.get().count ?? config.defaultText ?? 'N/A');
});
}
const composition = createComposition([counter, logCount, renderCount], {
initialState: { count: 0 },
config: { interval: 250, defaultText: '--' },
initialOwners: { renderElement: document.getElementById('counter') },
});
await composition.destroy();
renderCount reads from both channels. effect() tracks every signal the callback reads and re-runs when any of them change — so when count ticks up, or when renderElement is swapped out or cleared, the renderCount function runs again. The guard if (!renderElement) return handles the case where the element isn't in owners yet (for example, if initialOwners was omitted or the DOM wasn't ready).
initialOwners
initialOwners seeds the owners signal, the same way initialState seeds state:
createComposition([counter, logCount, renderCount], {
initialOwners: { renderElement: document.getElementById('counter') },
});
Its type is derived from the behaviors. Because renderCount annotates owners: Signal<{ renderElement?: HTMLElement }>, TypeScript requires initialOwners to be assignable to that shape:
// @ts-expect-error — renderElement expects an HTMLElement, not a number
createComposition([renderCount], { initialOwners: { renderElement: 42 } });
If you omit initialOwners, the signal starts as {} — which is why renderCount uses the optional annotation renderElement?: HTMLElement and guards the read.
Updating owners from outside
Owners is just a signal, so you can write to it the same way you write to state — usually from inside a behavior, occasionally from outside when orchestrating resources that live beyond the composition's scope.
Swapping the element mid-composition updates what renderCount is rendering into. Because effect() tracks owners, the swap re-runs the callback and the new element starts receiving updates immediately:
const anotherDiv = document.getElementById('other-counter');
update(composition.owners, { renderElement: anotherDiv });
Unsetting back to undefined is also fine — the guard (if (!renderElement) return) turns the absence into a no-op:
update(composition.owners, { renderElement: undefined }); // renderCount stops writing to the DOM
update(composition.owners, { renderElement: anotherDiv }); // and picks back up
The same pattern covers creation time: if you omit initialOwners, the signal starts as {}, the first effect run bails on the guard, and renderCount comes alive the moment a behavior (or outside code) attaches the element.
This is the loose-coupling payoff. renderCount doesn't need to know when renderElement will exist, only what to do when it does. Resources can arrive late, be swapped, or disappear — the behavior adjusts.
Composing behaviors
When you pass more than one behavior to createComposition, their declarations are combined and the compiler catches conflicts. The rule differs by channel.
State and config use intersection: if two behaviors declare the same key with incompatible types, the intersection collapses and the composition is rejected.
const expectsNumber = (_deps: { state: Signal<{ value: number }> }) => {};
const expectsString = (_deps: { state: Signal<{ value: string }> }) => {};
// @ts-expect-error — behaviors have conflicting state types
createComposition([expectsNumber, expectsString]);
Owners use subtype compatibility, because owner values are concrete platform objects whose class hierarchy matters. Two behaviors can share an owner key if one type extends the other — the composition picks the more specific one:
const wantsElement = (_deps: { owners: Signal<{ el?: HTMLElement }> }) => {};
const wantsVideo = (_deps: { owners: Signal<{ el?: HTMLVideoElement }> }) => {};
// ✅ HTMLVideoElement extends HTMLElement — fine
createComposition([wantsElement, wantsVideo]);
Sibling types with no extends relationship are rejected:
const wantsCanvas = (_deps: { owners: Signal<{ el?: HTMLCanvasElement }> }) => {};
const wantsVideo = (_deps: { owners: Signal<{ el?: HTMLVideoElement }> }) => {};
// @ts-expect-error — neither HTMLCanvasElement nor HTMLVideoElement extends the other
createComposition([wantsCanvas, wantsVideo]);
Reactors
The counter ticks on an interval whether you want it to or not. If you let users pause it, one approach is to check a paused flag inside the interval callback — but that leaves the interval running forever, just idling. What you really want is setup and teardown tied to the transition itself: the interval starts when the counter enters a "running" phase, and stops when it leaves.
An effect() can't cleanly model that. It re-runs on every signal change with no sense of phase. That's fine for "render the count into the DOM" — every change should re-render. It's wrong for "start a timer while paused is false." What you need is a state machine over signals: a reactor.
What it is — a state machine whose target state is derived from signals. Transitions run setup on entry and cleanup on exit.
When to use it — when a behavior has distinct phases with setup/teardown tied to signal-derived conditions, not just observation.
A pausable counter
A counter that can be paused and reset from DOM buttons. The counter is now a reactor; the rest are ordinary effect-based behaviors:
import { createComposition, effect, createMachineReactor, update, type Signal } from '@videojs/spf';
import { listen } from '@videojs/utils/dom';
// logCount, renderCount — unchanged from the previous section
function counter({
state,
config,
}: {
state: Signal<{ count?: number; paused?: boolean }>;
config: { interval?: number };
}) {
return createMachineReactor({
initial: 'paused',
monitor: () => (state.get().paused ? 'paused' : 'running'),
states: {
paused: {},
running: {
// entry runs once on transition; its return value is cleanup,
// called on exit (pause or destroy)
entry: () => {
const id = setInterval(() => {
update(state, { count: (state.get().count ?? 0) + 1 });
}, config.interval ?? 1000);
return () => clearInterval(id);
},
},
},
});
}
function pauseButton({
state,
owners,
}: {
state: Signal<{ paused?: boolean }>;
owners: Signal<{ pauseBtn?: HTMLElement }>;
}) {
// Keep the button label in sync with paused
const stopLabel = effect(() => {
const { pauseBtn } = owners.get();
if (!pauseBtn) return;
pauseBtn.textContent = state.get().paused ? 'Start' : 'Pause';
});
// listen() attaches a click handler and returns a cleanup that removes it
const stopClick = effect(() => {
const { pauseBtn } = owners.get();
if (!pauseBtn) return;
return listen(pauseBtn, 'click', () => {
update(state, { paused: !state.get().paused });
});
});
return () => {
stopLabel();
stopClick();
};
}
function resetButton({
state,
owners,
}: {
state: Signal<{ count?: number }>;
owners: Signal<{ resetBtn?: HTMLElement }>;
}) {
return effect(() => {
const { resetBtn } = owners.get();
if (!resetBtn) return;
return listen(resetBtn, 'click', () => update(state, { count: 0 }));
});
}
const composition = createComposition([counter, logCount, renderCount, pauseButton, resetButton], {
initialState: { count: 0, paused: true },
config: { interval: 250, defaultText: '--' },
initialOwners: {
renderElement: document.getElementById('counter'),
pauseBtn: document.getElementById('pause'),
resetBtn: document.getElementById('reset'),
},
});
await composition.destroy();
counter is now a reactor with two states, paused and running. Its monitor reads state.get().paused and returns the target. When the user clicks the pause button, pauseButton writes to state; monitor re-derives, and the reactor transitions. entry on running starts the interval; the cleanup it returns runs on the way back to paused. The framework handles the transition — counter never calls transition() itself.
pauseButton, resetButton, and renderCount are ordinary effect-based behaviors reacting to the same state the reactor derives from. None of them knows a reactor exists. Everything coordinates through the shared signal.
Monitor, entry, and effects
A reactor is defined with three core pieces:
monitor— a reactive function that returns the name of the target state. It re-evaluates when its signal dependencies change; if the result changes, the reactor transitions.entry— runs once when the reactor enters a state. Automatically untracked, so reads don't subscribe. Return a cleanup function (or an object withabort()), and it runs on exit.effects(not shown in the example) — live reactive effects scoped to a single state. They run while in that state, track their dependencies, and are cleaned up on exit. Use them when you want live sync within a phase, not just on entry.
Not every behavior needs a reactor. When all you need is to react to signal changes, effect() is enough. Reach for a reactor when different phases call for different setup and teardown, and the phase itself is derived from signal state.
Tasks
counter, renderCount, and the buttons all do their work synchronously — read a signal, write a signal, attach a handler. Saving that count to a server is different in kind. It's async, takes time, might fail, and you probably don't want overlapping requests when the count ticks faster than the network responds.
A plain Promise can't express any of that. It starts running the moment you create it, can't be cancelled, and offers no introspection into whether it's still in flight. What you want is an inspectable, abortable, schedulable unit of async work: a Task. And when multiple tasks line up behind each other — when counter hits 10 before the save at 5 has finished — you want a SerialRunner to queue them so they don't overlap.
What it is — a Task is an async unit of work with a synchronous lifecycle (pending / running / done / error), an AbortSignal for cancellation, and no execution until a runner schedules it. A SerialRunner is the simplest scheduler: first in, first out, one at a time.
When to use it — async work that needs inspection, cancellation, or ordering. Saves, uploads, chunked parses — anything where "two of these running at once" or "cancel this midway through" are real concerns.
A persist behavior
Saving the count to a server every five ticks, with one final save on destroy:
import { createComposition, effect, Task, SerialRunner, computed, type Signal } from '@videojs/spf';
// counter, logCount, renderCount, pauseButton, resetButton — unchanged from previous sections
function persist({
state,
config,
}: {
state: Signal<{ count?: number }>;
config: { saveEvery?: number };
}) {
const runner = new SerialRunner();
function save(count: number) {
runner.schedule(
new Task((signal) =>
fetch('/api/count', {
method: 'POST',
body: JSON.stringify({ count }),
signal,
}),
),
);
}
// Isolate count so unrelated state changes don't trigger a save
const count = computed(() => state.get().count ?? 0);
// Watch count and save at every Nth tick
const stopEffect = effect(() => {
const c = count.get();
if (c > 0 && c % (config.saveEvery ?? 5) === 0) {
save(c);
}
});
// Async cleanup: save the final count, let the runner drain, then tear down
return async () => {
stopEffect();
save(count.get());
await runner.settled;
runner.destroy();
};
}
const composition = createComposition(
[counter, logCount, renderCount, pauseButton, resetButton, persist],
{
initialState: { count: 0, paused: true },
config: { interval: 250, defaultText: '--', saveEvery: 5 },
initialOwners: {
renderElement: document.getElementById('counter'),
pauseBtn: document.getElementById('pause'),
resetBtn: document.getElementById('reset'),
},
},
);
// Saves at count 5, 10, 15, 20... and once more on destroy.
await composition.destroy();
Three things are new. The save() closure wraps each network request in new Task(...); the task's body receives an AbortSignal that fetch understands natively. The SerialRunner collects scheduled tasks and runs them one at a time — scheduling a second task while the first is still running queues it behind, with no overlap. And persist's cleanup is async: it stops the effect, schedules one last save, awaits runner.settled to let pending work finish, then destroys the runner. composition.destroy() awaits this cleanup like any other.
One more detail is worth unpacking: why count is wrapped in a computed before the effect reads it.
Narrowing what an effect re-runs on
Previous effects read count straight from state — state.get().count. Here it's wrapped in a computed:
const count = computed(() => state.get().count ?? 0);
The reason is the shape of the effect that reads it. save() is a non-idempotent side effect: it schedules a network request. A state signal re-notifies on every write, so reading state.get().count directly would re-run the effect whenever paused toggled (or any unrelated field changed) and fire a save whenever the current count happened to be divisible by saveEvery. computed caches by value, so reading its .get() inside an effect only triggers a re-run when that value actually changed.
For the DOM-update effects earlier in the doc — renderCount setting textContent, pauseButton's label — spurious re-runs are harmless: the assignment just writes the same value already there. The computed guardrail matters when the effect's side effect isn't free to repeat.
Task and SerialRunner
A Task holds the description of a piece of async work without starting it. Its constructor receives a function (signal: AbortSignal) => Promise<T>; the work doesn't begin until a runner picks it up. While 'pending', the task can be aborted, inspected, or dropped from the queue with no wasted effort. Once 'running', the AbortSignal fires on cancellation — which fetch and any signal-aware API handle cleanly.
A SerialRunner is the simplest scheduler: first in, first out, one at a time. runner.schedule(task) returns a promise that resolves to the task's result when it eventually runs. Two useful read points:
runner.settled— a promise that resolves once the runner has no pending or running tasks.await runner.settledbefore teardown lets in-flight work finish.runner.abortPending()/runner.abortAll()— cancel pending work, or everything including the current task. Useful when a newer request supersedes older ones.
SerialRunner is the simplest scheduler, but not the only one: ConcurrentRunner runs tasks in parallel and deduplicates by task.id, which fits request batches where "the same thing twice" should collapse. Both accept any TaskLike, so building your own runner — rate-limited, priority-ordered, whatever your workload needs — is a supported extension point, not a workaround.
Reach for Task when you need control over async work: ordering, cancellation, or the ability to reason about what's in flight. For one-shot async work that you'd be happy to await and discard, a plain Promise is still fine.
Actors
persist does the job, but it keeps the save lifecycle to itself. If one behavior wanted to show "saving..." while a save is in flight, or another wanted to cancel an outstanding save when the user hits reset, neither could — persist owns the runner and the "am I saving?" state internally, without publishing them.
Moving that work into an actor makes the save lifecycle observable. An actor is a message-driven state machine that owns a resource (here, the task runner), processes messages through its own transitions, and publishes its current state as a reactive snapshot that any behavior can subscribe to.
What it is — a message-driven state machine that owns a mutable resource. Other behaviors send() messages to it; it transitions between states internally; its current state is exposed as a reactive snapshot signal.
When to use it — when the imperative work a behavior performs is observable state the rest of the composition needs to see or drive. "Is a save in flight?", "Did the last request succeed?", "Please cancel what you're doing" — if any of that should be visible to other behaviors, you've outgrown a plain task.
A save actor
Refactor persist: the runner and save state move into an actor; persist sends messages to it; a new renderSaving behavior reads the actor's snapshot to surface in-flight status to a dedicated element; a new cancelOnReset behavior sends cancel when count returns to zero while a save is in flight.
import { createComposition, effect, createMachineActor, Task, SerialRunner, computed, update, type Signal } from '@videojs/spf';
// counter, logCount, renderCount, pauseButton, resetButton — unchanged from previous sections
function createSaveActor() {
function makeSaveTask(count: number) {
return new Task(async (signal) => {
await fetch('/api/count', {
method: 'POST',
body: JSON.stringify({ count }),
signal,
});
return { lastSaved: count };
});
}
return createMachineActor({
runner: () => new SerialRunner(),
initial: 'idle',
context: { lastSaved: undefined as number | undefined },
states: {
idle: {
on: {
save: (msg, { transition, runner, setContext }) => {
transition('saving');
runner.schedule(makeSaveTask(msg.count)).then(setContext);
},
},
},
saving: {
onSettled: 'idle',
on: {
// A new save while one is in-flight: drop pending, schedule fresh
save: (msg, { runner, setContext }) => {
runner.abortPending();
runner.schedule(makeSaveTask(msg.count)).then(setContext);
},
cancel: (_msg, { transition, runner }) => {
runner.abortAll();
transition('idle');
},
},
},
},
});
}
type SaveActor = ReturnType<typeof createSaveActor>;
function persist({
state,
owners,
config,
}: {
state: Signal<{ count?: number }>;
owners: Signal<{ saveActor?: SaveActor }>;
config: { saveEvery?: number };
}) {
const actor = createSaveActor();
update(owners, { saveActor: actor });
// Isolate count so unrelated state changes don't trigger a save
const count = computed(() => state.get().count ?? 0);
const stopEffect = effect(() => {
const c = count.get();
if (c > 0 && c % (config.saveEvery ?? 5) === 0) {
actor.send({ type: 'save', count: c });
}
});
return () => {
stopEffect();
actor.destroy();
};
}
function renderSaving({
owners,
}: {
owners: Signal<{ savingElement?: HTMLElement; saveActor?: SaveActor }>;
}) {
return effect(() => {
const { savingElement, saveActor } = owners.get();
if (!savingElement) return;
savingElement.textContent = saveActor?.snapshot.get().value === 'saving' ? 'saving...' : '';
});
}
function cancelOnReset({
state,
owners,
}: {
state: Signal<{ count?: number }>;
owners: Signal<{ saveActor?: SaveActor }>;
}) {
// Isolate count so unrelated state changes don't fire spurious cancels
const count = computed(() => state.get().count);
return effect(() => {
const { saveActor } = owners.get();
if (count.get() === 0 && saveActor?.snapshot.get().value === 'saving') {
saveActor.send({ type: 'cancel' });
}
});
}
const composition = createComposition(
[counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset],
{
initialState: { count: 0, paused: true },
config: { interval: 250, defaultText: '--', saveEvery: 5 },
initialOwners: {
renderElement: document.getElementById('counter'),
savingElement: document.getElementById('saving'),
pauseBtn: document.getElementById('pause'),
resetBtn: document.getElementById('reset'),
},
},
);
await composition.destroy();
The actor makes the save lifecycle observable. persist publishes the actor through owners and forwards save triggers as messages; renderSaving reads saveActor.snapshot.get().value and writes "saving..." to its own dedicated element when the actor is in the 'saving' state; cancelOnReset reads the same snapshot and sends a cancel message when count returns to zero during an in-flight save, aborting the work and transitioning the actor back to idle. None of these behaviors knows how a save is performed — they interact with the actor as a black box that happens to expose its current state. And resetButton no longer has to know anything about saving: it just writes { count: 0 } to state; cancelOnReset handles the rest.
Note
Actors live in
ownershere because they fit the shape — imperative resources with identity that behaviors observe and act on. Whether they warrant a dedicated channel is an open question. Treat actors-in-owners as a working convention, not a fixed design.
Messages, transitions, and snapshot
An actor is defined by its states, its message handlers (on), and a snapshot signal it publishes.
- Messages —
actor.send({ type: 'save', count: 42 }). Each state declares the messages it accepts underon; unhandled messages are ignored. The handler receives the message plus a context exposingtransition,setContext, and the actor's ownrunner. - Transitions — call
transition('saving')inside a handler to move the actor to another state. Transitions are explicit in the handlers rather than derived the way a reactor'smonitoris.onSettledlets you auto-transition when the runner finishes: here, thesavingstate drops back toidleonce the scheduled task settles, with no manual bookkeeping. - Snapshot —
actor.snapshotis aSignal<{ value: string; context: Context }>that publishes the actor's current state name and context. Any behavior that reads it inside aneffect()re-runs on every transition orsetContextcall, which is what letsrenderSavingwrite "saving..." withoutpersistpublishing a separate "am I saving?" flag.
The division of labor:
- Reactors decide when something should happen, by observing signals.
- Actors handle how it happens, by managing imperative work and publishing its status.
A reactor might send a message to an actor when a condition goes true; the actor performs the work and publishes its state; other behaviors observe that state through the snapshot signal. Each piece stays in its lane.
Advanced: Creating owners within behaviors
Up through Actors, the caller had to hand every element the composition uses directly into initialOwners — renderElement, savingElement, pauseBtn, resetBtn, each looked up from the DOM before createComposition runs. Adding or renaming any of them means touching the caller too. Behaviors can close that loop: given a single rootElement, a behavior creates the descendants itself, registers them in owners, and cleans them up on teardown. That keeps resource creation inside the composition, which matters when a single composition needs several related resources that share a lifecycle.
A mount behavior takes a single parent — rootElement — and creates the rest:
import { effect, update, type Signal } from '@videojs/spf';
// counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset — unchanged from previous sections
function mount({
owners,
}: {
owners: Signal<{
rootElement?: HTMLElement;
renderElement?: HTMLElement;
savingElement?: HTMLElement;
pauseBtn?: HTMLElement;
resetBtn?: HTMLElement;
}>;
}) {
return effect(() => {
const { rootElement } = owners.get();
if (!rootElement) return;
const renderElement = document.createElement('div');
const savingElement = document.createElement('div');
const pauseBtn = document.createElement('button');
const resetBtn = document.createElement('button');
resetBtn.textContent = 'Reset';
rootElement.append(renderElement, savingElement, pauseBtn, resetBtn);
update(owners, { renderElement, savingElement, pauseBtn, resetBtn });
// If this behavior needed cleanup when rootElement is cleared — to
// .remove() the descendants, or close a socket, observer, or
// MediaSource — we'd return a cleanup function from the effect here.
});
}
const composition = createComposition(
[counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset, mount],
{
initialState: { count: 0, paused: true },
config: { interval: 250, defaultText: '--', saveEvery: 5 },
initialOwners: { rootElement: document.getElementById('counter') },
},
);
mount reads rootElement, creates four descendant elements, attaches them to the DOM, and writes them back into owners. The other behaviors — renderCount, renderSaving, pauseButton, resetButton — pick them up through the guards we've already written; none of them knows a mount step happened. On destroy, the descendants are discarded along with rootElement, and the composition clears every key in owners after all behavior cleanups have run — no manual bookkeeping on either side.
Each descendant is registered under its own key in owners rather than left for other behaviors to pick out of rootElement themselves. The alternative — watch the subtree with a MutationObserver and identify elements by selector or data attribute — works mechanically, but trades away most of what owners gives you. You lose typed identity (renderElement: HTMLElement is not the same contract as "some <div> inside rootElement"), you swap synchronous guards for coalesced microtask callbacks, and every downstream behavior becomes coupled to whatever DOM layout mount happens to produce. Owners is a signal of named resources; behaviors reading it never have to know where those resources came from, only that they appeared.
This is also where behaviors-as-units pays off. The contract every downstream behavior relies on is typed owners plus guards for missing keys — nothing about how the owners got populated. The same composition supports several equally valid shapes:
- Omit
mount. PassrenderElement,savingElement, and the buttons throughinitialOwnersdirectly, or write them later viacomposition.owners.set(...). This is the shape the Actors example uses. - Replace
mountwith a different implementation. A shadow-DOM variant, a React-rendered variant, one that clones a<template>, one that adopts pre-existing elements — any of them can slot in as long as they write the same keys. - The one above.
mountowns subtree creation and teardown itself.
None of the other behaviors change across those permutations. Substitution at the behavior boundary — rather than at the composition boundary — is what makes that possible.
Advanced: Wrapping a composition in a public API
Compositions expose state, owners, and destroy — the right surface for behaviors and internal authors, too much and too low-level for outside consumers. Someone using your counter component doesn't want to write update(composition.state, { paused: true }); they want counter.pause(). And they don't want to subscribe to a signal; they want to addEventListener('countchange', ...).
A wrapper sits in front of the composition and projects exactly the surface you choose. A Counter class that extends EventTarget gives consumers a DOM-shaped API:
import { createComposition, effect, computed, update, type Composition } from '@videojs/spf';
// counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset, mount — unchanged from previous sections
type CounterState = { count?: number; paused?: boolean };
type CounterOwners = {
rootElement?: HTMLElement;
renderElement?: HTMLElement;
savingElement?: HTMLElement;
pauseBtn?: HTMLElement;
resetBtn?: HTMLElement;
};
interface CounterOptions {
rootElement: HTMLElement;
initialCount?: number;
paused?: boolean;
tickIntervalMs?: number;
placeholder?: string;
autoSaveEveryTicks?: number;
}
class Counter extends EventTarget {
readonly #options: Required<CounterOptions>;
readonly #composition: Composition<CounterState, CounterOwners>;
#teardowns: Array<() => void> | undefined;
constructor(options: CounterOptions) {
super();
this.#options = {
initialCount: 0,
paused: true,
tickIntervalMs: 250,
placeholder: '--',
autoSaveEveryTicks: 5,
...options,
};
this.#composition = createComposition(
[counter, logCount, renderCount, pauseButton, resetButton, persist, renderSaving, cancelOnReset, mount],
{
initialState: { count: this.#options.initialCount, paused: this.#options.paused },
config: {
interval: this.#options.tickIntervalMs,
defaultText: this.#options.placeholder,
saveEvery: this.#options.autoSaveEveryTicks,
},
initialOwners: { rootElement: this.#options.rootElement },
},
);
// One effect per state-derived event. Each reads a single `computed`,
// so it only re-runs when that specific value changes — no local
// "what changed this time?" diffing across fields.
const count = computed(() => this.#composition.state.get().count);
const paused = computed(() => this.#composition.state.get().paused);
this.#teardowns = [
effect(() => {
this.dispatchEvent(
new CustomEvent('countchange', {
detail: count.get() ?? this.#options.initialCount,
}),
);
}),
effect(() => {
this.dispatchEvent(new Event(paused.get() ? 'pause' : 'play'));
}),
];
}
get count(): number {
return this.#composition.state.get().count ?? this.#options.initialCount;
}
get paused(): boolean {
return this.#composition.state.get().paused ?? this.#options.paused;
}
pause(): void {
update(this.#composition.state, { paused: true });
}
resume(): void {
update(this.#composition.state, { paused: false });
}
reset(): void {
update(this.#composition.state, { count: 0 });
}
async destroy(): Promise<void> {
if (!this.#teardowns) return;
for (const stop of this.#teardowns) stop();
this.#teardowns = undefined;
await this.#composition.destroy();
}
}
From outside, consumers see something that looks and feels like a native DOM object:
const counter = new Counter({
rootElement: document.getElementById('counter-root'),
tickIntervalMs: 100,
paused: false,
});
counter.addEventListener('countchange', (e) => {
console.log('count:', (e as CustomEvent<number>).detail);
});
counter.addEventListener('play', () => console.log('running'));
counter.addEventListener('pause', () => console.log('paused'));
counter.resume();
console.log(counter.count); // 3
console.log(counter.paused); // false
await counter.destroy();
No signals in sight. No composition.state.get(), no update(...), no effect(). The wrapper maps every piece of the composition surface onto a consumer-shaped primitive: the constructor takes one flat options bag, getters project current state, methods forward to update(), and a single bridging effect() translates state transitions into events on this (which is an EventTarget, so addEventListener and dispatchEvent just work).
The options shape is also its own translation layer. Consumers don't see the state / config / initialOwners split the composition uses internally; they pass one bag of named values. The constructor merges those over defaults into a #options object, then splits it across createComposition — initialCount and paused seed state, tickIntervalMs / placeholder / autoSaveEveryTicks become internal config keys (interval, defaultText, saveEvery), and rootElement seeds owners. #options stays around so the getters can reuse those defaulted values — count falls through to this.#options.initialCount when state is unset. reset() keeps its own hard-coded 0: that value triggers the composition's cancelOnReset behavior, so it belongs to the internal contract rather than the caller-facing options.
The bridge is one effect per derivation, not one effect that diffs every field on each run. count and paused are pulled out as computed signals; each gets its own effect dispatching the matching event; and the pair of stop functions is held on #teardowns. No lastCount / lastPaused flags to keep in sync, and each effect re-runs only when its own derivation's value changes. The inverse shape — a single effect that reads the whole state and compares each field to a local let last* — is imperative diffing wearing a reactive costume; it works, but it stops scaling the moment a third or fourth field shows up.
destroy() is the one place the wrapper's own cleanup lives. It iterates #teardowns to stop every bridging effect, clears the array so a second destroy() is a no-op, then awaits this.#composition.destroy() to tear down every behavior. Consumers get a single Promise to await.
More events can be wired the same way — a saving/saved pair reading saveActor.snapshot, a destroy event dispatched before teardown, custom events derived from any signal worth surfacing. Each is another computed + effect pushed onto #teardowns. The pattern scales: every piece of composition state that matters to a consumer gets its own projection.
Bridge as a behavior?
The #teardowns array is a small piece of manual lifecycle tracking — we create effects, hold their stop functions, run them on destroy. That's the same shape we had at the very start of the doc, when setInterval and stopLogging lived outside the composition and the caller had to manage both. Moving them inside as behaviors tied their cleanup to composition.destroy() automatically. So why isn't the bridge a behavior too?
It could be. A forwardEvents behavior that took the EventTarget itself as an owner would work:
function forwardEvents({
state,
owners,
}: {
state: Signal<{ count?: number; paused?: boolean }>;
owners: Signal<{ target?: EventTarget }>;
}) {
const count = computed(() => state.get().count);
const paused = computed(() => state.get().paused);
const stopCount = effect(() => {
const { target } = owners.get();
if (!target) return;
target.dispatchEvent(new CustomEvent('countchange', { detail: count.get() ?? 0 }));
});
const stopPaused = effect(() => {
const { target } = owners.get();
if (!target) return;
target.dispatchEvent(new Event(paused.get() ? 'pause' : 'play'));
});
return () => {
stopCount();
stopPaused();
};
}
The wrapper would pass this through initialOwners.target, and #teardowns would disappear — composition.destroy() would handle it.
We didn't do that, because the bridge is adapter work, not composition work. Dispatching CustomEvent('countchange') knows about the public API shape — the event name, the detail payload, the fact that a consumer is listening to an EventTarget at all. None of that has anything to do with the composition's domain. Leaving the bridge on the wrapper keeps that split clean: behaviors do composition work, the wrapper translates to the consumer-shaped surface. The #teardowns bookkeeping is a small cost paid to keep composition concerns from leaking out.
This is the Adapter shape — the composition stays generic; the wrapper projects whatever surface the consumer expects.
Bringing it all together
By the end of the last section, we'd built a running counter with an internal tick, a logger, a DOM renderer, two buttons, a save pipeline, a save actor with observable status, automatic cancel-on-reset, a root-creating mount behavior, and an adapter wrapper with a DOM-shaped public API — nine behaviors in total, each a small function, each unaware of the others. Everything above this section taught how that works by doing. This section pulls the patterns out into the open: what holds across every composition, what's always optional, and what the model is really asking of you.
Additive, not rewriting
Every section added new behaviors without touching the ones that came before. counter and logCount stayed the same from State onward; renderCount stayed the same from Owners onward; the buttons from Reactors onward. New capabilities landed as appended behaviors in the composition list, not as edits to existing ones.
The exceptions are informative. counter was redefined in Reactors — its internal setInterval became a reactor-managed effect so it could enter and leave a running state. persist was redefined in Actors — its runner and in-flight flag moved into a dedicated actor, and persist itself shrank to a message forwarder. Neither rewrite reached outside the behavior being replaced: counter still read and wrote state.count; persist still triggered on the same Nth-tick condition. The contract each behavior exposed stayed stable; the implementation swapped.
That's the normal way to extend a composition. Replace a behavior with a differently-shaped version of itself; add new behaviors for new capabilities; don't reach into existing behaviors to adjust them.
Use what you need
None of the primitives are mandatory. A composition with one behavior that increments count is a real composition — no reactors, no tasks, no actors, no owners, no wrapper. Each primitive exists to solve a problem that a simpler shape couldn't:
- Reactors earn their keep when lifecycle depends on state — setup runs while a condition holds, teardown fires when it stops, and nothing has to guard "is this still relevant?" on each tick.
- Tasks earn their keep when async work needs introspection or cancellation. If a plain Promise works, a plain Promise is fine.
- Actors earn their keep when the imperative work a behavior performs is observable state the rest of the composition needs to see or drive.
- Owner-creating behaviors earn their keep when a composition needs several related resources that share a lifecycle.
- Adapter wrappers earn their keep when the public API shape diverges from the composition's internal signals.
Each is reachable when the problem becomes real, and sits out of the way when it doesn't. The cost of unused primitives is zero — you never imported them.
Contracts, not couplings
No behavior in this doc imported another. renderCount doesn't know counter exists; cancelOnReset doesn't know persist or the actor factory by name. The only interface between behaviors is the shape of their shared signals — state keys, owners keys, config keys. If two behaviors agree on a key and its type, they can coordinate; if they don't share a key, they're invisible to each other.
That's why additive composition works. A new behavior only needs to declare which keys it reads and writes. The composition-level type system takes care of merging its declarations with everyone else's and fails at compile time if a key's type disagrees across behaviors. Nothing has to be taught about a new behavior; nothing gets broken by removing one.
Conventions we leaned on
A handful of conventions keep showing up because they pay off across every shape a composition can take. All of them surface earlier in this doc; this is just the index.
- Guard for missing owners. Every behavior that reads an owner checks
if (!ownerKey) return;before using it. Owners can arrive late or be cleared, and effects re-run when they do; the guard is what makes that safe. - Isolate dependencies with
computed. When an effect's side effect is non-idempotent (scheduling a save, dispatching an event), wrap the specific field the effect cares about incomputedso an unrelated state write doesn't re-fire it. - One effect per derivation. When a wrapper translates multiple state fields into separate events, each gets its own
effectreading its owncomputed. Single-effect-with-diff-flags is imperative tracking wearing a reactive costume. - Options bag, flat. Public constructors take one caller-facing options bag; the wrapper splits it across
initialState/config/initialOwnersinternally. Consumers never see the split. - Cleanup returned, not scattered. Behaviors return their cleanup function (or an object with
destroy()), and the composition runs them all on destroy. External lifetime management (like the#teardownsarray in the Counter wrapper) is only for things outside the composition.
Everything in this doc is fundamentals. Real compositions do more — more behaviors, richer state, deeper owner graphs, multiple actors — but none of the primitives change shape. Once the composition model is familiar, the rest is your domain.