Async Transformations in Reactivity

Structured async behavior in a reactive event system This post is part of the Transformations in Reactivity series — an exploration of how ideas from modern signal-based systems (like SolidJS 2.0) can be extended to event-based reactivity. In this chapter, we focus on async coordination: giving event systems the same structural lifecycle handling that createAsync brought to signals — including pending state, error propagation, and scoped retries. 1. The Async Challenge Signals and events each approach async behavior differently — and each has its limitations. Signals are synchronous. They represent current state and return values immediately, but can't represent "waiting" unless the developer adds manual flags or wrappers. Events, on the other hand, are naturally asynchronous. They push values over time — but their lifecycle is flat. There’s no structured way to know when an async handler starts, only when it finishes. Solid 2.0 introduced structural async handling to signals. Can we do the same for events? 2. Structural Async in Solid 2.0 Solid 2.0's createAsync integrated async state into signals structurally: Unresolved signals throw to suspend. Errors propagate up the graph. Derived signals and effects remain clean. This made async "colorless" — the graph handles lifecycle transitions without leaking async details into consumer code. Can we design an equivalent model for events that maintains their async push model, but adds structure for pending, error, and retry states? 3. Representing Async in Events Events traditionally emit next(), error(), and optionally complete(). But they lack structure for intermediate async states. To mirror signals' suspension behavior, we introduce wait() — a lifecycle notification emitted when async work starts. This lets downstream dependents know something is in progress. With this, events gain a structured lifecycle: wait(), next(), error(). The graph can now react to pending states declaratively. 4. Composable Async with createAsyncEvent and createSyncEvent createAsyncEvent Transforms a source event and an async function into a lifecycle-aware event: const onValidated = createAsyncEvent(onSubmit, async (form) => { await validate(form) }) Usage: createListener(onValidated, { wait: () => console.log("Validating..."), next: () => console.log("Validation passed"), error: (err) => console.error("Validation failed", err) }) createSyncEvent Converts an event lifecycle into a Promise for use in signal-based boundaries: const [promise, setPromise] = createSignal() createListener(createSyncEvent(onValidated), setPromise) const ready = createAsync(() => promise()) This bridges async events with Suspense and error boundaries cleanly. 5. Scoped Retry with Async Event Context Async events establish a scoped event context shared with downstream dependents. This context exists synchronously across async boundaries. The async handler can register a retry() function into this context. Listeners can invoke it structurally: createListener(onValidated, { error: (err) => { getEventContext()?.retry() } }) This retry mechanism is: Scoped to the current async flow. Isolated from sibling branches. Available declaratively in error callbacks. Because each async operation clones and propagates its context, retry remains accessible across the entire event graph. 6. Background Work with runTransaction Event context enables scoped execution environments across async boundaries. With runTransaction, we can defer side effects until the entire flow is complete: const result = await runTransaction(() => { emitSubmit() emitAnotherAction() }) Inside a transaction: wait() and next() notifications are deferred. Errors are surfaced through the returned promise. Side effects are suppressed until completion. This allows for "background" async work without leaking intermediate states into the UI. Unlike global transactions in Solid or React, each runTransaction here is fully scoped and supports concurrent flows. Transactions and Signals Events manage flow. Signals manage state. For complete transactional behavior: Events provide scoped async context and flow control. Signals provide state forking and rendering isolation. A unified API can compose both: runTransaction (events) startTransition (signals) Ensuring transactional updates complete only when both flow and state are settled. Conclusion By structuring async lifecycles, scoping retries, and deferring side effects through transactions, we can bring event systems to parity with modern signals — while retaining their natural strengths as push-based, stateless primitives. Next: we’ll explore how to bring granular propagation to events, ensuring updates only notify the parts of the graph that actually care.

May 15, 2025 - 07:00
 0
Async Transformations in Reactivity

Structured async behavior in a reactive event system

This post is part of the Transformations in Reactivity series — an exploration of how ideas from modern signal-based systems (like SolidJS 2.0) can be extended to event-based reactivity.

In this chapter, we focus on async coordination: giving event systems the same structural lifecycle handling that createAsync brought to signals — including pending state, error propagation, and scoped retries.

1. The Async Challenge

Signals and events each approach async behavior differently — and each has its limitations.

Signals are synchronous. They represent current state and return values immediately, but can't represent "waiting" unless the developer adds manual flags or wrappers.

Events, on the other hand, are naturally asynchronous. They push values over time — but their lifecycle is flat. There’s no structured way to know when an async handler starts, only when it finishes.

Solid 2.0 introduced structural async handling to signals. Can we do the same for events?

2. Structural Async in Solid 2.0

Solid 2.0's createAsync integrated async state into signals structurally:

  • Unresolved signals throw to suspend.
  • Errors propagate up the graph.
  • Derived signals and effects remain clean.

This made async "colorless" — the graph handles lifecycle transitions without leaking async details into consumer code.

Can we design an equivalent model for events that maintains their async push model, but adds structure for pending, error, and retry states?

3. Representing Async in Events

Events traditionally emit next(), error(), and optionally complete(). But they lack structure for intermediate async states.

To mirror signals' suspension behavior, we introduce wait() — a lifecycle notification emitted when async work starts. This lets downstream dependents know something is in progress.

With this, events gain a structured lifecycle: wait(), next(), error(). The graph can now react to pending states declaratively.

4. Composable Async with createAsyncEvent and createSyncEvent

createAsyncEvent

Transforms a source event and an async function into a lifecycle-aware event:

const onValidated = createAsyncEvent(onSubmit, async (form) => {
  await validate(form)
})

Usage:

createListener(onValidated, {
  wait: () => console.log("Validating..."),
  next: () => console.log("Validation passed"),
  error: (err) => console.error("Validation failed", err)
})

createSyncEvent

Converts an event lifecycle into a Promise for use in signal-based boundaries:

const [promise, setPromise] = createSignal()

createListener(createSyncEvent(onValidated), setPromise)

const ready = createAsync(() => promise())

This bridges async events with Suspense and error boundaries cleanly.

5. Scoped Retry with Async Event Context

Async events establish a scoped event context shared with downstream dependents. This context exists synchronously across async boundaries.

The async handler can register a retry() function into this context. Listeners can invoke it structurally:

createListener(onValidated, {
  error: (err) => {
    getEventContext()?.retry()
  }
})

This retry mechanism is:

  • Scoped to the current async flow.
  • Isolated from sibling branches.
  • Available declaratively in error callbacks.

Because each async operation clones and propagates its context, retry remains accessible across the entire event graph.

6. Background Work with runTransaction

Event context enables scoped execution environments across async boundaries.

With runTransaction, we can defer side effects until the entire flow is complete:

const result = await runTransaction(() => {
  emitSubmit()
  emitAnotherAction()
})

Inside a transaction:

  • wait() and next() notifications are deferred.
  • Errors are surfaced through the returned promise.
  • Side effects are suppressed until completion.

This allows for "background" async work without leaking intermediate states into the UI.

Unlike global transactions in Solid or React, each runTransaction here is fully scoped and supports concurrent flows.

Transactions and Signals

Events manage flow. Signals manage state.

For complete transactional behavior:

  • Events provide scoped async context and flow control.
  • Signals provide state forking and rendering isolation.

A unified API can compose both:

  • runTransaction (events)
  • startTransition (signals)

Ensuring transactional updates complete only when both flow and state are settled.

Conclusion

By structuring async lifecycles, scoping retries, and deferring side effects through transactions, we can bring event systems to parity with modern signals — while retaining their natural strengths as push-based, stateless primitives.

Next: we’ll explore how to bring granular propagation to events, ensuring updates only notify the parts of the graph that actually care.