DEV Community

Cover image for What If Reactive Execution Doesn't End at Rendering?
Luciano0322
Luciano0322

Posted on

What If Reactive Execution Doesn't End at Rendering?

In the previous two articles, we started with what looked like a simple question:

const user = createMemo(async () => {
  return fetchUser(id());
});
Enter fullscreen mode Exit fullscreen mode

The first article asked:

Once computed values can be async, why does a reactive runtime stop managing only values and start managing executions?

The second pushed the question further:

When an execution becomes pending, where should the UI actually wait?

Solid 2.0's idea of:

Fetch High, Block Low

shows an important distinction:

Where async work starts ≠ where the UI blocks

Or, put another way:

Execution Boundary ≠ Rendering Boundary

But if we take that idea one step further, we arrive at a more fundamental question:

Does reactive execution have to end in rendering?

This was also one of the main questions behind my original design of signal-kernel and async-runtime.

If the core capabilities of a reactive system are:

dependency tracking
+
change propagation
+
incremental recomputation
Enter fullscreen mode Exit fullscreen mode

then why should they only be useful for updating a UI?


I Never Wanted the Kernel to Own Rendering

When I started designing signal-kernel, I made one decision fairly early:

The kernel itself should not own rendering.

I wanted the reactive graph to exist independently of any UI framework.

The architecture looks roughly like this:

graph TD

    A["signal-kernel/core"]

    B["Reactive graph"]

    C["framework adapter"]

    D["React"]

    E["Vue"]

    A-->B-->C-->D

    C-->E

React and Vue are not the owners of the reactive graph.

They are consumers of the graph.

That may sound like little more than a package boundary, but it ends up affecting a lot of later design decisions.

If the runtime does not own rendering, it cannot assume that this sequence always exists:

computation pending
↓
Suspense
↓
UI fallback
Enter fullscreen mode Exit fullscreen mode

The same runtime might be running in:

  • a browser
  • a worker
  • a server
  • a CLI
  • an agent

Some of those environments may not have a UI at all.

So once async work enters the reactive graph, the runtime should no longer be asking:

What should the screen display?

It needs to ask:

What state is this execution in right now?


Rendering Is Just One Consumer

Once rendering is removed from the reactive runtime itself, the model starts to look more like this:

graph TD

    A["Reactive Graph"]

    B["Async Runtime"]

    C["Execution State"]

    D["React"]

    E["Vue"]

    F["Worker"]

    G["Server"]

    H["Agent"]

    A-->B-->C-->D

    C-->E

    C-->F

    C-->G

    C-->H

At that point:

  • pending
  • resolved
  • rejected
  • stale
  • cancelled
  • superseded

are no longer UI states.

They are:

Execution states.

React might translate pending into a loading state.

Vue might choose to represent it differently.

A worker may render nothing at all and simply wait until some other piece of work can continue.

An agent runtime might inspect pending or stale to decide whether a node needs to run again.

The responsibility of the reactive runtime changes accordingly.

It no longer needs to answer:

How should this be rendered?

But it still has to answer questions like:

  • Is this execution still valid?
  • Who is allowed to cancel it?
  • Has a newer execution replaced it?
  • Should its result still propagate when it completes?

Async Work Can't Be Reduced to a Promise

Once rendering is no longer the center of the model, another problem becomes much harder to ignore:

Are all forms of async work really the same thing?

At the JavaScript level, they may all return a Promise:

await fetchUser();
await updateUser();
await waitForMessage();
Enter fullscreen mode Exit fullscreen mode

But their semantics are completely different.

The first is closer to a Query. Its main purpose is to obtain a value.

The second is a Mutation. It changes something outside the runtime.

The third, if it represents a long-lived Stream, may not even have a meaningful point where "the Promise has completed" and the work is done.

If we look only at the JavaScript type Promise<T>, it is very easy to collapse all of these into a single abstraction.

From the runtime's point of view, though, they need to answer very different questions.


Query, Mutation, and Stream Have Different Execution Semantics

Consider a Query first:

Query
↓
read external state
↓
produce a value
Enter fullscreen mode Exit fullscreen mode

The runtime may need to ask:

  • When does it become stale?
  • Should it execute again?
  • Can the previous result remain usable temporarily?

A Mutation is different:

Mutation
↓
modify external state
Enter fullscreen mode Exit fullscreen mode

The important question is not only whether it succeeded.

It is:

What did it change?

For example, after updateUser() succeeds, this may mean that:

user
profile
permissions
Enter fullscreen mode Exit fullscreen mode

can no longer be trusted.

This introduces a relationship that dependency tracking alone cannot derive:

Invalidation.

A Stream has yet another execution model:

Stream
↓
first value
↓
second value
↓
third value
↓
...
Enter fullscreen mode Exit fullscreen mode

Its lifecycle is closer to:

start
→ receive
→ receive
→ receive
→ cancel / complete
Enter fullscreen mode Exit fullscreen mode

Now the runtime also has to care about things like:

  • session ownership
  • stable value
  • cancellation
  • long-lived lifecycle

So while Query, Mutation, and Stream are all asynchronous work, the runtime should not treat them as identical executions.


Dependency Is Not the Same as Invalidation

Reactive graphs are very good at describing dependencies.

For example:

price
↓
subtotal
↓
total
Enter fullscreen mode Exit fullscreen mode

When price changes, subtotal naturally becomes stale.

That relationship can be discovered by tracking which computations read which values.

Mutation introduces a different kind of relationship.

Suppose:

After updateProfile() completes, userProfile needs to be fetched again.

But updateProfile() may never read userProfile while it runs.

That means dependency tracking alone cannot derive:

updateProfile
→ invalidates userProfile
Enter fullscreen mode Exit fullscreen mode

Dependency describes:

Who do I need in order to compute myself?

Invalidation describes:

Whose trustworthiness changes because I happened?

Those are not the same relationship.

You can think of it like this:

graph LR

    A["User ID"]

    B["Update User Mutation"]

    C["User Query"]

    D["Profile View"]

    A--"depends on"-->C--"depends on"-->D

    B--"invalidates"-->C

This is one reason why a plain dependency graph may stop being enough once a reactive runtime begins managing asynchronous work.


The Runtime Starts Needing to Know Whether an Execution Is Still Eligible

When we discussed async computed values earlier, we already ran into this situation:

Execution A
Execution B
Enter fullscreen mode Exit fullscreen mode

If both exist at the same time, which result is allowed to affect the current state?

Once async work becomes a first-class runtime concept, this question appears everywhere.

For example:

revision 1

→ start query A

revision 2

→ start query B

B resolves

→ commit

A resolves later

→ discard
Enter fullscreen mode Exit fullscreen mode

What the runtime is really managing here is:

Whether a particular execution is still eligible to affect the current state.

That requires more than a value.

It also requires concepts such as:

  • execution identity
  • revision
  • cancellation
  • supersession

This is why concepts like revisions and cancellation gradually became part of async-runtime.

Once rendering is no longer hiding these details for you, the runtime itself has to define the execution lifecycle clearly.


Cancellation Is Also an Ownership Problem

A normal async function can use an AbortController.

But from the runtime's perspective, the more fundamental question is:

Who has the authority to cancel this execution?

Consider a component being unmounted:

Component
↓
cancel request
Enter fullscreen mode Exit fullscreen mode

This effectively means that the component owns the async lifecycle.

But what if the async work does not belong to the component?

For example:

  • an agent runtime
  • a worker
  • a shared data graph

In that case:

UI disappeared
Enter fullscreen mode Exit fullscreen mode

should not automatically mean:

execution should stop
Enter fullscreen mode Exit fullscreen mode

This is one of the problems that naturally appears once rendering ownership and async ownership are separated.

Render lifecycle ≠ Async lifecycle
Enter fullscreen mode Exit fullscreen mode

Cancellation should be controlled by whoever actually owns the execution.


Streams Make This Difference Even More Obvious

Long-lived streams are a good example.

For instance:

  • WebSocket
  • SSE
  • MQTT
  • event subscriptions

Their lifecycle usually looks more like this:

connect
↓
receive
↓
receive
↓
receive
↓
disconnect
Enter fullscreen mode Exit fullscreen mode

If the stream is directly tied to a component lifecycle:

  • component mount → connect
  • component unmount → disconnect

then the component naturally becomes the stream owner.

But what if the stream is being used for:

  • job monitoring
  • shared application state
  • background processing
  • agent events

Its lifetime may be longer than that of any individual UI consumer.

At that point, the UI no longer consuming the stream and the stream session should end cannot mean the same thing.

This is also why ownership became increasingly important to me while designing createStreamResource.


Once Rendering Is No Longer the Endpoint, Snapshot Means Something Different Too

If a runtime exists only to support a UI, a snapshot can easily be interpreted as:

Save the state the current screen needs.

But for an execution runtime, the more interesting question is:

Can we restore the state of the computation itself?

For example:

  • Which values are already stable?
  • Which executions are still pending?
  • Which dependencies are stale?
  • What revision are we currently on?

And potentially even:

Can the relationships inside the graph itself be preserved?

At that point, a snapshot is no longer just:

JSON.stringify(state)
Enter fullscreen mode Exit fullscreen mode

It starts to look more like:

runtime state persistence
Enter fullscreen mode Exit fullscreen mode

That can be useful for SSR hydration.

It can help with debugging.

It can also support workflow recovery.

And once you move into agent systems, the value of that capability becomes even more obvious.


Why Are Agents a Natural Next Domain?

An agent workflow looks very different from frontend rendering on the surface.

For example:

Goal
↓
LLM
↓
Tool
↓
Observation
↓
Decision
↓
Retry / Continue / Stop
Enter fullscreen mode Exit fullscreen mode

But if you look at it through the lens of execution semantics, many of the problems are surprisingly familiar to a reactive runtime.

For example:

  • context changed → which computations are now stale?
  • tool result arrived → which downstream nodes need to be reevaluated?
  • a new LLM execution started → is the old execution still valid?
  • a correction occurred → which results need to be validated again?
  • the workflow stopped → which executions should be cancelled?

None of these are really rendering problems.

They are much closer to:

  • dependency
  • execution
  • invalidation
  • ownership
  • lifecycle

In other words:

graph TD

    A["Goal / Context"]

    B["LLM Execution"]

    C["Tool Result"]

    D["Derived State"]

    E["Invalidation / Correction"]

    A-->B-->C-->D

    E-->D

    D-->B

In an imperative workflow, this might be expressed as:

step 1

step 2

if something changed

go back to step 1
Enter fullscreen mode Exit fullscreen mode

From a reactive graph perspective, however, it becomes:

dependency changed
↓
affected execution becomes stale
↓
runtime reevaluates necessary work
Enter fullscreen mode Exit fullscreen mode

This Isn't About Forcing Frontend Reactivity Into AI

There is one part of this idea that I think is easy to misunderstand.

If I say:

Use signals in an agent.

it sounds like I am taking a frontend library and trying to transplant it into AI.

But I now think the more useful way to frame the problem is:

Frontend was simply one of the first domains where reactive execution models were adopted at scale.

A typical frontend flow looks like this:

state changes
↓
derived state changes
↓
render changes
Enter fullscreen mode Exit fullscreen mode

But if we remove the final step:

state changes
↓
derived computation changes
↓
dependent work changes
Enter fullscreen mode Exit fullscreen mode

there is nothing inherently UI-specific about that model.

The more general concept is:

Dependency-driven execution
Enter fullscreen mode Exit fullscreen mode

This is one reason I wanted signal-kernel to be independent of rendering frameworks from the beginning.

Only after separating Reactivity from Rendering can we really test the question:

Can reactivity work as a general-purpose execution model?


This Is Where Solid and signal-kernel Start to Diverge

The previous two articles used a lot of Solid 2.0's design as an example because Solid has pushed the integration of async computation into the reactive graph quite far.

Its direction can roughly be understood as:

Reactive Graph
↓
Async Execution
↓
Suspense / Transition
↓
Rendering
Enter fullscreen mode Exit fullscreen mode

That is a very reasonable form of vertical integration.

Its goal is to make reactive execution and UI rendering work together more naturally.

The direction I chose is closer to:

Reactive Graph
↓
Async Runtime
↓
Execution Semantics
↓
Consumer
Enter fullscreen mode Exit fullscreen mode

The consumer might be:

  • React
  • Vue
  • Worker
  • Server
  • Agent

Neither direction is inherently more correct.

They simply draw the architectural boundary in different places.

Solid owns rendering, so it can directly integrate pending into:

Suspense / Transition
Enter fullscreen mode Exit fullscreen mode

async-runtime does not own rendering, so concepts such as:

pending

stale

revision

cancel

invalidation
Enter fullscreen mode Exit fullscreen mode

need to be defined as execution semantics in their own right.

That difference is what eventually pushes the two approaches toward different problem domains.


Reactive Execution Doesn't Require a UI

Let's return to the question that started this three-part series.

We began with:

createMemo(async () => ...)
Enter fullscreen mode Exit fullscreen mode

The first article led to this idea:

Async pushes a reactive system from managing values toward managing executions.

The second led to:

An execution being pending and a rendering boundary being blocked are two different boundaries.

And now the third takes one more step:

If rendering is just a consumer, reactive execution itself does not need to be limited to UI.

A reactive runtime can begin to describe:

  • which work depends on which state
  • when work needs to execute again
  • which results have become stale
  • which execution is still valid
  • who is allowed to cancel work
  • which operations invalidate other data

These questions appear in:

  • frontend systems
  • server workflows
  • background processing
  • agent runtimes

So I have increasingly come to think of reactivity as:

A dependency-driven execution model.

Rendering is one extremely important and mature application of that model.

But it does not necessarily have to be the endpoint.

And once rendering is truly removed from the core, the next question becomes:

How should a general-purpose reactive runtime represent ownership, invalidation, and correctness across executions?

That is the direction I want to keep exploring next.

Top comments (5)

Collapse
 
erlanggasatriasource profile image
Erlangga Satria •

on my perspective of web api, reactivity just concept of running function on class instance, like useState in reat is factory with result of array object, then rename it as 'nameofstate' and 'setnameofstate', when we user web api, proxy, we binding set of function will be execute or we called trap. when we added render function so its end rendering, or when we added funtion to make change to other proxy, when I experiment with vanilla js, make proxy I added event listerer of localstorage event then set state so its "pseudo communication" betwen tab for sync state. the every tab state change render on their each tab browser.

Collapse
 
luciano0322 profile image
Luciano0322 •

Yeah, I think the cross-tab example is actually a good way to look at it. The state change and propagation can exist independently from rendering, while each tab can decide how to consume that change. That separation is very close to what I was trying to explore in this article.

Collapse
 
erlanggasatriasource profile image
Erlangga Satria •

I actually built reactive engine, its use web api, proxy, domparser and tree Walker. As I know since ES5 browser more strong and apply what framework simulator before, now old device obsolete, modern browser win the game. Reactive engine just for render, history api or baseline2026 navigation for router, and pure proxy if we wanna setup global state and multi tab sync

Thread Thread
 
luciano0322 profile image
Luciano0322 •

I think we're looking at reactivity at two different architectural levels.
What you're describing makes sense for a browser-centric reactive engine, where Proxy, DOM updates, routing, and shared state are the main concerns.
What I'm exploring in this article is a different boundary: whether dependency tracking and execution semantics need to belong to rendering at all.
In signal-kernel, rendering is intentionally only one consumer. The runtime also needs to reason about things like invalidation, revisions, cancellation, streams, and superseded async executions, even when there is no DOM involved.
So I don't think the question here is really about whether modern browser APIs are powerful enough to build a reactive engine. It's whether reactive execution itself has to be defined as a rendering mechanism.

Thread Thread
 
erlanggasatriasource profile image
Erlangga Satria •

Yeah its my already statement reactivity is function in class instance, in every enviromment, its pure my definition, etc class book, save([..book]) then inside save there was function to save db, to trap if author nameAuthor. Why we called framework "reactive framework" maybe because its setf of the way to specialized change DOM instant. Its primitive name was AJAX. Reactive framework was marketing brand. Simple as we code in many form function with sequential step