A Smaller Signals Standard
JavaScript frameworks all ended up at reactive programming. They just didn’t end up at the same kind of reactive programming.
Signals, computed values, stores, effects, watchers—every major framework has its own graph implementation, its own tracking rules, its own scheduling model, its own lifecycle semantics. Moving reactive logic between frameworks means rewriting it. Composing two reactive libraries usually means they can’t even see each other’s dependencies.
The TC39 Signals proposal tries to fix this by standardizing an integrated reactive graph: writable signals, automatic dependency tracking, equality-aware propagation, watcher semantics, the whole thing.
That might be the right call. But there’s another option worth considering: standardize less. Specifically, standardize an invalidation protocol and a lazy, cached derivation primitive with explicit dependencies. Let frameworks handle automatic tracking, equality, scheduling, ownership, and effects on top.
Here’s what that might look like:
const changed = Reactive.createSource();
const total = Reactive.derive(
[changed],
() => calculateTotal(),
);
Enter fullscreen mode Exit fullscreen mode
The source doesn’t hold state. It just announces that something may have changed.
let count = 0;
const countChanged = Reactive.createSource();
count++;
countChanged.invalidate();
Enter fullscreen mode Exit fullscreen mode
A derivation declares what can make it stale:
const label = Reactive.derive(
[countChanged],
() => `Count: ${count}`,
);
Enter fullscreen mode Exit fullscreen mode
Call label.get() and it evaluates if needed, otherwise returns cache. That’s it.
This is intentionally less ambitious than full signals. That restraint might make it a better standard.
Why smaller?
ECMAScript features are basically permanent. Once a reactive architecture ships in the language, every framework either adopts it or works around it for years. The abstraction boundary matters a lot.
Some reactive concepts are genuinely universal:
- Dependencies can become invalid.
- Derived values can be evaluated lazily.
- Successful evaluations can be cached.
- Invalidation can propagate transitively.
- Consumers need to know when to re-check.
- Subscriptions eventually need cleanup.
Others vary wildly between frameworks:
- Automatic vs. explicit dependency tracking.
- Equality semantics (Object.is? structural? version numbers?).
- Graph liveness and memory management.
- Ownership trees.
- Effect scheduling and batching.
- Transaction semantics.
- Rendering integration.
- Error boundaries.
A smaller proposal standardizes the mechanism. It doesn’t standardize the policies built on top.
Framework policy
────────────────────────────────────
Signals, stores, proxies
Automatic dependency tracking
Equality and versioning
Effects and scheduling
Ownership and batching
Rendering integration
────────────────────────────────────
Standardized substrate
Invalidation protocol
Lazy cached derivation
Dependency replacement
Notification
Cleanup
Enter fullscreen mode Exit fullscreen mode
Frameworks get a common foundation. They keep room to experiment above it.
Protocol interoperability, not graph unification
The current proposal’s big selling point is interoperability: two reactive systems participating in the same automatic-tracking context. It achieves this by moving everyone onto a shared reactive graph.
The alternative achieves interoperability through a behavioral protocol:
dependency.onInvalidate(listener);
Enter fullscreen mode Exit fullscreen mode
Anything implementing that can be a dependency. Not just built-in signals. Proxies. Immutable state containers. Reactive collections. Class instances. Caches. Remote resources. Framework-native signals. Even plain mutable objects paired with an invalidation source.
Example:
class ReactiveStore {
#source = Reactive.createSource();
#data = new Map();
get(key) {
return this.#data.get(key);
}
set(key, value) {
this.#data.set(key, value);
this.#source.invalidate();
}
onInvalidate(listener) {
return this.#source.onInvalidate(listener);
}
}
Enter fullscreen mode Exit fullscreen mode
Another library derives from it without knowing its internals:
const summary = Reactive.derive(
[store],
([store]) => calculateSummary(store),
);
Enter fullscreen mode Exit fullscreen mode
The systems agree on how possible change is communicated. They don’t have to agree on how values are stored, how reads are tracked, how equality works, or how ownership is represented.
That’s a narrower interoperability. It might also be a more adoptable one.
Same architecture, different dependency discovery
Both approaches use the same broad shape: write pushes invalidation through the graph synchronously, framework decides when work matters, values are pulled lazily.
The difference is how edges get discovered.
Automatic tracking:
const total = new Signal.Computed(
() => price.get() * quantity.get(),
);
Enter fullscreen mode Exit fullscreen mode
Explicit dependencies:
const total = Reactive.derive(
[price, quantity],
([price, quantity]) =>
price.get() * quantity.get(),
);
Enter fullscreen mode Exit fullscreen mode
Automatic tracking is often more ergonomic. Explicit dependencies are more visible and less contextual.
The tradeoff isn’t reactive vs. non-reactive programming. It’s two ways of building the same dependency graph.
Why explicit dependencies might matter for a standard
Automatic tracking introduces ambient execution context. Calling get() does different things depending on whether some outer computation is currently recording reads. You need untrack() and APIs to expose the current computation.
Explicit dependencies remove that ambient context.
Reactive.derive(
[account, exchangeRates],
computeBalance,
);
Enter fullscreen mode Exit fullscreen mode
The edge is declared where the derivation is created.
This means:
- Dependencies are visible in code review.
- Unrelated callbacks can’t accidentally become graph dependencies.
- Tools can inspect intended edges without running the computation.
- Ordinary reads keep ordinary JavaScript semantics.
- Cross-realm behavior doesn’t depend on a shared current-tracker context.
- Callbacks can call into abstraction layers without implicitly capturing every reactive read underneath.
The cost: you have to declare dependencies correctly. Dynamic dependency selection needs careful API design so the values controlling selection are themselves in the graph.
This isn’t obviously better for app developers. It might be a better semantic foundation for a language standard, though.
State stays where it is
A value-bearing signal isn’t the only useful reactive abstraction. State already lives in:
- Private class fields
- Maps and sets
- Proxies
- Immutable snapshots
- Database-backed models
- Native objects
- Remote caches
- Framework-specific stores
A valueless invalidation source lets those systems participate without moving their data into a standardized container.
let count = 0;
const countChanged = Reactive.createSource();
function increment() {
count++;
countChanged.invalidate();
}
Enter fullscreen mode Exit fullscreen mode
You can still build a familiar writable signal on top:
function signal(value, equals = Object.is) {
const changed = Reactive.createSource();
return {
get() { return value; },
set(next) {
if (equals(value, next)) return;
value = next;
changed.invalidate();
},
onInvalidate(listener) {
return changed.onInvalidate(listener);
},
[Symbol.dispose]() {
changed[Symbol.dispose]();
},
};
}
Enter fullscreen mode Exit fullscreen mode
Value storage and equality policy stay outside the standardized core.
Equality becomes a framework decision
One of the most valuable features of an integrated signal graph is equality-aware propagation: a computed value gets invalidated, reevaluates, produces the same result, and the graph prevents downstream work.
The smaller alternative makes a weaker guarantee: invalidation means a dependency may have changed. That’s conservative, not value-sensitive.
Some systems use Object.is. Others use structural equality, version numbers, collection-specific checks, or no comparison at all. This flexibility matters for dependencies that don’t represent a single comparable value—like an entire mutable store or a remote resource.
The cost is real: conservative invalidation schedules more work and causes more reevaluation. Whether that tradeoff is acceptable needs framework prototypes and benchmarks, not just API aesthetics.
Scheduling stays with frameworks
The current proposal (correctly) avoids standardizing a general effect() because scheduling is too tied to rendering, batching, transactions, and framework lifecycle.
The explicit alternative reaches the same conclusion. An invalidation callback is a wake-up signal:
view.onInvalidate(() => {
queueMicrotask(() => view.get());
});
Enter fullscreen mode Exit fullscreen mode
A framework might instead use:
- An animation frame
- A priority queue
- A server-rendering pass
- A transaction-aware scheduler
- A synchronous test harness
- A component rendering queue
The primitive reports that work may be stale. The framework decides when and how to run it.
Safety during propagation
Reactive invalidation isn’t automatically a stable snapshot. In a graph with converging branches, one path may invalidate before another. If user code reads the graph during propagation, it sees partially invalidated state.
Any serious proposal needs to handle this.
The smaller alternative can preserve the same safety invariant as the current Signals design:
- Internal invalidation propagation completes synchronously.
- Public callbacks are scheduling notifications, not immediate work.
- Reactive reads and writes are prohibited during the notification phase.
- Consumers schedule later evaluation instead of rendering immediately.
A smaller API doesn’t mean ignoring the soundness problems the existing proposal identified. It means keeping the necessary invariant without standardizing the entire watcher architecture around it.
Explicit cleanup instead of hidden liveness
Reactive graphs create memory-management problems. If a computed value subscribes to its dependencies, those dependencies keep it alive. An integrated runtime can try to infer liveness and disconnect unused nodes automatically.
The smaller alternative uses deterministic disposal:
using total = Reactive.derive(
[price, quantity],
computeTotal,
);
Enter fullscreen mode Exit fullscreen mode
Or:
total[Symbol.dispose]();
Enter fullscreen mode Exit fullscreen mode
This gives predictable cleanup and fits JavaScript’s explicit resource-management model.
Upsides:
- Cleanup is observable and testable.
- Subscription removal doesn’t depend on GC timing.
- Engines don’t need a hidden graph-liveness algorithm.
- User-defined dependencies participate without exposing internal reachability.
- Frameworks can tie disposal to component, owner, request, or scope lifetimes.
Downside: forgetting to dispose retains subscriptions.
That’s a deliberate tradeoff. The question for TC39 is whether implicit liveness is universal and implementable enough to standardize, or whether explicit lifetime is the more conservative foundation.
Asymmetry
Here’s maybe the strongest argument.
Start with a small explicit core, and libraries can add:
explicit reactive substrate
├── writable signals
├── equality-aware computed values
├── automatic tracking
├── proxy stores
├── ownership scopes
├── effects
└── rendering systems
Enter fullscreen mode Exit fullscreen mode
Start with a complete built-in graph, and frameworks can’t remove its ambient tracking semantics, equality model, liveness behavior, watcher topology, or dependency-discovery rules.
They can ignore those features, but that undermines the interoperability benefits that justified standardization in the first place.
A smaller protocol might be a more durable meeting point between systems with different internal architectures.
What this maps to
Signals goal Explicit alternative Shared reactive infrastructure Standard dependency and invalidation protocol Framework interoperability Explicit cross-library dependencies Lazy computed values Lazy cached derivations Memoization Cached successful evaluations Dynamic dependencies Explicit dependency replacement Framework-controlled scheduling No built-in scheduler or effect Rendering independence No component or DOM concepts Support for stores Existing stores implement the protocol Multiple reactive styles Signals, proxies, classes, collections can wrap the core Cross-realm composition Behavioral protocol, notinstanceof
Cleanup
Deterministic disposal
Native optimization
Engines can optimize subscriptions and derivations
Shared vocabulary
Dependency, invalidation, stale, derivation, disposal
Meaningful overlap. Not complete equivalence.
What this gives up
A credible alternative should be explicit about its costs.
Shared automatic tracking. The current proposal lets libraries participate in the same ambient tracking context. The explicit alternative doesn’t provide that automatically. Libraries interoperate through declared dependencies, but they don’t inherently share a current computation.
Equality-minimal propagation. The explicit model guarantees conservative invalidation, not minimal recomputation. Frameworks can add equality-aware layers, but the base protocol doesn’t ensure unchanged computed values suppress downstream work.
Automatic graph liveness. Strong subscriptions need explicit disposal. Unused computed nodes that remain connected to live dependencies aren’t automatically garbage-collected.
Complete engine-visible graphs. A protocol with user-defined dependency objects is less inspectable than a graph of built-in signal objects. Native derivations can be inspected, but engines and dev tools may not understand every framework-owned edge.
These are real differences. They should be evaluated, not minimized.
The actual disagreement
The current Signals proposal isn’t misguided. Signals, computed values, lazy evaluation, scheduling, reactive interoperability—all valuable.
The disagreement is about how much of the architecture ECMAScript should standardize.
The current proposal standardizes a shared reactive runtime.
The alternative standardizes a shared reactive protocol and lazy derivation substrate.
That smaller boundary could still support portable reactive models, cached computation, transitive invalidation, framework-controlled scheduling, and cross-library composition. At the same time, it would leave automatic tracking, equality, graph liveness, ownership, effects, and rendering open to continued ecosystem development.
That might make it the stronger standardization candidate—if TC39 concludes that those higher-level policies aren’t yet universal, stable, or implementation-independent enough to become permanent JavaScript semantics.
The deciding evidence should come from real integrations. Prototype this beneath several distinct reactive systems. Evaluate it against the current proposal on interoperability, ergonomics, rendering correctness, recomputation overhead, memory retention, scheduler integration, dynamic dependencies, developer tooling, and implementation complexity.
The goal isn’t to pick the smallest API because small is good. The goal is to find the smallest standard that creates meaningful ecosystem coordination without freezing more reactive policy than JavaScript needs.
답글 남기기