Files
v10/internal/design/spf/conventions/signals.md
T

14 KiB

status, date
status date
draft 2026-05-07

Signals

Signals are SPF's reactive substrate — every Behavior uses them, and the Actors and Reactors a Behavior reaches for are themselves built on signals. "When to use a signal" isn't really a question in practice; what comes up is the contract: per-slot read/write intent, seeding for slots no behavior writes, multi-writer cases, and bridging writes from outside the composition. This doc covers those contract questions.

For the implementation — signal(), computed(), effect(), and the underlying primitives — see ../signals.md, ../primitives.md, and the TC39 Signals proposal.

Slot intent: Signal<T> vs ReadonlySignal<T>

Every state and context slot in a composition is annotated per behavior with read/write intent through the setup parameter type:

  • Signal<T> — the behavior writes to this slot (and may also read).
  • ReadonlySignal<T> — the behavior reads this slot only. Calling .set() on it is a type error.

This is a real contract enforced by the type system, not documentation. The same physical signal in the composition appears as Signal<T> in the behaviors that own writes and as ReadonlySignal<T> in the behaviors that consume it.

function setup({
  state,
  context,
}: {
  state: { preload: Signal<...> };               // this behavior writes preload
  context: { mediaElement: ReadonlySignal<...> }; // this behavior reads mediaElement
}) { ... }

Decision criterion

For each slot a behavior touches, ask:

  • Does the body call .set() on it? → Signal<T>.
  • Does the body only call .get() (or pass to computed / effect)? → ReadonlySignal<T>.

When in doubt, default to ReadonlySignal<T>. Tightening from Signal<T> to ReadonlySignal<T> later is a no-op for callers; loosening the other way isn't.

Multi-writer slots

The general guidance is 0-or-1 writer behaviors per slot, but multi-writer slots are accepted as legitimate patterns when they fit one of these shapes:

Pattern Example Notes
Intent + default selectedVideoTrackId written by switchQuality (ABR-derived default) and by external code (user intent) Decompose when the writers diverge enough to confuse readers; documented as a TODO in quality-switching.ts
Pipeline Stage 1 writes raw, Stage 2 writes processed Acceptable when stages are sequenced by composition order
Two-way DOM sync Behavior writes from DOM events, handler writes from external API Common for input-shaped DOM properties

If a slot has more than one writer and doesn't fit one of these shapes, that's a smell — it likely wants decomposition into separate slots with a derived signal computing the resolved value.

When the multi-writer is a behavior-decomposition smell rather than a slot-decomposition smell: the writers share a decision-making domain (same inputs, same options) rather than reflecting genuinely different inputs. That's a signal of one purpose split across two behaviors, not one slot that needs splitting. See behaviors.md → Decomposition check for the diagnostic.

A custom linter rule that warns on multi-writer slots with a // writer-audit-allow: <reason> ignore mechanism is a planned follow-up. Until it lands, the writer audit in .claude/plans/spf/discrete-signals-and-behavior-objects.md documents the legitimate cases.

Seeding 0-writer slots: initialState / initialContext

A slot with zero writer behaviors must be seeded via the composition's initialState or initialContext option. The type system requires this — if no behavior declares write intent, the seed is mandatory, not optional.

createComposition({
  behaviors: [...],
  initialState: { preload: 'auto' },          // required — no behavior writes preload
  initialContext: { mediaElement: undefined } // required — no behavior writes mediaElement
});

Use initialState / initialContext for constants and shaped initial values. Time-varying inputs from outside the composition go through shareSignals instead (see below).

External writes: shareSignals

When something outside the composition needs to drive composition state (an adapter, a sandbox harness, an engine consumer), use the shareSignals behavior factory rather than reaching into the composition's internals.

const composition = createComposition({
  behaviors: [
    ...otherBehaviors,
    makeShareSignals<EngineState, EngineContext>(),
  ],
  config: {
    onSignalsReady: ({ state, context }) => {
      // capture writable refs for use later
      mediaElementRef = context.mediaElement;
      preloadRef = state.preload;
    },
  },
});

// elsewhere — drive composition state from outside
mediaElementRef.set(element);

When to use shareSignals

  • An adapter pushes external values into the composition (e.g. SpfMedia writing mediaElement, preload, presentation, playbackInitiated).
  • A test or harness needs to inject values to drive a behavior's reactivity.
  • Cross-IPC / web-worker scenarios where the writer is in a different realm — the callback shape is preserved across the boundary, the writes happen by reference.

When NOT to use shareSignals

  • When the value is constant at composition time. Use initialState / initialContext — same effect, less ceremony.
  • When the value is derived from other signals. Use a behavior with computed or an effect writing into a state slot — keeps the dataflow inside the composition.
  • When you find yourself wanting to read composition state from outside and act on it. Prefer adding a Behavior that exposes the concern in-composition; reaching across the boundary to read inverts the dependency.

Where signals show up in Actors, Reactors, and Tasks

Reaching for a primitive doesn't escape signals — it embeds them in a particular shape. Knowing where signals already live in each primitive prevents duplicate-state hazards.

  • createTransitionActor / createMachineActor — the actor's snapshot is itself a Signal. A Behavior that observes an actor reads the snapshot directly; it doesn't (and shouldn't) maintain a parallel state slot mirroring the same value.
  • createMachineReactor — the reactor's monitor function reads signals to derive its current state; per-state effects re-run when their signal dependencies change. Reactors are signal-consumers by construction.
  • Task + Runner — Task context is not a Signal. If reactive observation of a Task's status is needed, the Behavior owns a Signal that mirrors the status, set from the Task's lifecycle hooks.

Corollary: when a primitive already exposes its state as a signal (Actor snapshot, Reactor state), don't introduce a parallel state slot for the same value. Two representations drift; one of them will eventually be wrong.

Helper functions for working with signals

A handful of utilities in core/signals/primitives cover recurring patterns. Reach for them at sites where the unsugared form would otherwise repeat.

peek(signal, transform?)

Read a signal's current value without tracking it as a dependency. Sugar for untrack(() => signal.get()) to reduce boilerplate at single-read sites.

const value = peek(someSignal);
const id = peek(presentationSignal, (p) => p?.id);

The optional second argument is a transform applied in the same call; the default is a type-inferred identity so the single-arg form returns T unchanged.

When to use:

  • Inside a reactor's per-state effect when the reactor's monitor already tracks the signal at the state-machine level. Tracking it again creates redundant re-runs on internal updates that don't change the state. See reactors.md → "Reading non-tracked signals inside effects."
  • One-off reads from non-reactive contexts (task bodies, callbacks). Mostly equivalent to signal.get() since there's nothing to untrack from, but reads more clearly as "I'm intentionally not subscribing."
  • In one direction of a bidirectional-sync behavior when the effect reads-and-writes the same slot but should only re-run on upstream changes (not on its own writes or its partner effect's writes). The partner effect subscribes normally; the peek-side effect uses peek to break the self-trigger loop. See behaviors.md → "Multi-effect behaviors" and syncPreload for the worked example.

equalsById(a, b)

Equality comparator for objects with an optional id field. Designed for use as a computed equals option when the consumer should react to identity changes (Ham-shaped objects, JSON-API-shaped resources) but ignore internal updates that preserve the id.

const presentationByIdSignal = computed(() => state.presentation.get(), {
  equals: equalsById,
});

Handles undefined inputs symmetrically: both undefined → equal; one undefined → different.

When to use: filtering out internal-update churn when a downstream consumer cares about presentation identity but not internal updates that preserve the id. Less common when the consumer is reactor-shaped (the state machine already filters at the state level), but useful for read-only consumers that can't or shouldn't restructure as a reactor.

update(signal, updaterFn | partial)

Atomic read-then-write. Two overloads:

  • Updater function: update(signal, (current) => next) — works for any signal type, including Signal<T | undefined>.
  • Partial object: update(signal, partial) — merges partial into the current state. Requires T extends object.
update(state, { playbackRate: 2 });
update(state.presentation, (current) =>
  isResolvedPresentation(current) ? updateTrackInPresentation(current, mediaTrack) : current
);

Prefer the function form when the updater needs to handle different shapes (e.g., undefined vs. resolved). The partial form is sugar for the merge case where the caller is supplying just the changed fields.

Anti-patterns

  • Typing a write-only slot as ReadonlySignal<T> because the type happened to import that way. Subsequent .set() calls will be type errors at the body, not at the import.
  • Reaching for Signal.State directly instead of the composition's signal map in a behavior body. Composition slots are constructed by createComposition for a reason — the engine destroy loop relies on the map being canonical.
  • Using shareSignals to bridge two parts of the same composition. If both ends are inside the composition, write a Behavior. shareSignals is for crossing the composition boundary.
  • Capturing a Signal<T> ref via shareSignals and writing to a slot you didn't formally declare ownership of. This bypasses the per-behavior write annotation. Convention: external writers via shareSignals should be treated as "the canonical owner of this slot for write purposes," and no behavior in the composition should also declare write intent on it. (The current adapter-driven slots — mediaElement, preload, presentation, playbackInitiated — follow this rule.)
  • Defining a state slot for a value that never changes after setup. A Signal that's set once and never updated is just a constant with extra plumbing — close over it or seed it via initialState / initialContext.
  • Maintaining a parallel state slot for an Actor's snapshot. The snapshot is already a Signal; observe it directly. A duplicate slot drifts.
  • Framing a design choice as "signal vs Actor" or "signal vs Reactor." Signals are the substrate either way — the real choice (covered in behaviors.md) is what additional primitive lives under the signals.

Follow-ups

This doc is intentionally scoped to the contract questions (read/write intent, seeding, multi-writer, external writes, primitive embedding). Several adjacent topics are live but not yet documented; capturing them here so they're tracked in-place rather than only in conversation:

  • Signal-shape decomposition patterns. When to take a flag-shaped slot (abrDisabled) and decompose it into intent-shaped slots (userSelectedVideoTrackId written by external code, plus an ABR-derived counterpart) with the resolution happening in a computed or pushed to config. The multi-writer section names "intent + default" as a legitimate pattern; the how-to (naming conventions for intent slots, where the resolution lives, when config beats a derived signal) wants its own section once the planned abrDisabled refactor lands and there's a real shape to document.
  • computed best practices beyond equalsById. When to lift a derivation into a computed vs inline .get() calls, predicate-shaped computeds vs inline guards in effect bodies, computed-of-computed, what computed does and doesn't memoize. (One concrete piece already documented: in reactor-shaped behaviors, source-identity filtering via custom equals is often unnecessary — the state machine handles relevant transitions at a different layer; see reactors.md.)
  • effect best practices. Cleanup return-value conventions, AbortController integration, the perils of nested effects (briefly covered in behaviors.md sniffs but signal-specific guidance belongs here), batching, when an effect should become a createMachineReactor (see reactors.md).
  • Extending the SPF signals library. The peek / equalsById / update-overload landings (commits abe38a48 / 51b0d9db / d8e97753) are concrete examples of the pattern: when a recurring shape across behaviors warrants a named helper in core/signals/, surface it as a candidate, build it generically (no domain-specific assumptions), and consume it in the original site as a follow-up commit. Decision criteria mirror the missing-primitive bucket in behaviors.md → name the existing helpers you considered before declaring a gap, and prefer overloads / structural typing over breaking-change signature redesigns.

Each of these is a candidate for a sub-section under this doc; some may grow large enough to deserve their own doc (computed.md, effects.md). Defer that decision until the content forces it.