What if reactive effects are just pausable async tasks?
Source: Dev.to
Three things Rust already gives you for free
Pin – a suspended computation
Think about it: a reactive effect is “a piece of code paused, waiting for its dependencies to change, then re‑executing.” That’s exactly what a future stuck at Poll::Pending is.
scope.spawn(async move {
// The SignalChangedFuture::poll() checks a monotonic version number.
// If unchanged, it subscribes a callback and returns Poll::Pending.
// The async executor polls other tasks.
// When the signal changes, the version bumps, the callback fires,
// the waker wakes, the executor re‑polls.
});
The executor’s poll loop is the effect scheduler—no separate scheduler, no effect‑graph traversal, no create_effect() abstraction.
Waker – the notification dispatcher
A reactive library must decide “which effects need to re‑run when signal X changes.” In the async model this maps directly to “which futures should be re‑polled.” The waker is that dispatch mechanism:
fn poll(self: Pin, cx: &mut Context) -> Poll {
// …
}
No separate notification queue, no topological sort of dependents—just “wake the waker, executor picks it up.”
Drop – cleanup
Dropping the scope that owns the tasks drops all futures; each future’s Drop impl proactively unsubscribes from its signals, leaving zero dangling subscribers. No manual on_cleanup() tokens, no effect‑disposal bookkeeping. Rust’s ownership does the work.
For deeply nested scopes (e.g., UI component trees with hundreds of levels), cancellation uses a breadth‑first search to collect all descendants, then iterates leaf‑to‑root—no recursive drop, no stack overflow.
The 20 % Rust doesn’t give you: re‑entrancy prevention
Pure async breaks down when Signal::set() calls subscriber callbacks synchronously:
set() → callback → set() on same signal → callback → infinite loop
The async model alone can’t prevent this; you need a deferred‑notification state machine.
Approach
Signal::set()never invokes callbacks directly.- It bumps the version, snapshots the subscriber list, and pushes a closure into the executor’s deferred‑callback queue.
- The executor drains this queue at the start of every flush, before polling any tasks.
State table
| notifying | dirty | Description |
|---|---|---|
| false | false | Normal. Snapshot subscribers, push notification, set dirty. |
| true | false | Callbacks running. |
| … | true | A re‑entrant set() happened; schedule a follow‑up notification with a fresh snapshot (so newly‑added subscribers are included). |
This covers every edge case:
- Re‑entrant
set()during a callback round. - Subscribe/unsubscribe while iterating callbacks.
- Scope drop during a callback (cancellation flag stored in a
Celloutside theRefCell, always writable). set()from aDrop(deferred to the next flush viaset_deferred()).
The trade‑off
A traditional reactive scheduler can run effects in topological order—dependencies before dependents—guaranteeing each memo recomputes at most once. An async executor runs tasks in whatever order they wake up. If two effects both read a dirty memo, they might each trigger its recomputation; the second read sees the memo already clean and skips the work, but the first poll of each effect still performs the version check.
- Correctness is preserved—version checking plus lazy memo recompute guarantees eventual consistency.
- Optimality is best‑effort, not guaranteed.
For UI‑frame‑level reactivity this is fine. For a compiler’s incremental analysis it probably isn’t. The simplicity trade‑off feels worth it for most applications, but I’m curious about counter‑arguments.
Why bother?
If you squint, reactive programming and async programming solve the same problem from different directions. One suspends computation waiting for data changes; the other suspends computation waiting for I/O. The primitives overlap almost completely.
The question isn’t “can you build reactivity on async?”—it’s “why wouldn’t you?”
The only genuinely novel piece needed is a safe way to fire subscriber callbacks without re‑entrancy. Everything else already exists in the language.
I built a working implementation of this (~1800 lines across two crates, #![forbid(unsafe_code)], zero external deps for the signal layer). I’d love technical feedback, especially on:
- Whether the lack of topological ordering has bitten anyone in real UI projects, or if it’s mostly a theoretical concern.
- Whether there’s a simpler alternative to the generational slot table I’m using for executor routing.
- Any missed edge cases or trade‑offs that feel wrong.
This is still my own learning process, and I’d rather get corrected now than discover issues later.