A minimal, easy-to-understand implementation of JavaScript Signals based on the TC39 Signals Proposal. This implementation prioritizes clarity and educational value over performance optimizations.
This library is designed for people with solid JavaScript knowledge to get a better understanding of the TC39 Signals proposal.
Signals have become extremely popular in the JavaScript framework ecosystem. Some popular examples are:
Signal.State<T>
- Writable reactive stateSignal.Computed<T>
- Derived values that automatically track dependenciesSignal.subtle.Watcher
- Low-level API for observing signal changes
effect(fn)
- Side effects that run when dependencies change. This is just added for educational purposes. In reality, this would be handled by the framework.
- Auto-tracking: Computed signals automatically discover their dependencies
- Pull-based: Computations are lazy and only run when accessed
- Cached: Results are memoized until dependencies change
- Synchronous notifications: Watchers are notified immediately when signals change
const firstName = new Signal.State("John");
const lastName = new Signal.State("Doe");
// This computed automatically tracks both firstName and lastName
const fullName = new Signal.Computed(() => {
console.log("Computed function called!");
return `${firstName.get()} ${lastName.get()}`;
});
// First access - the function runs!
console.log(fullName.get());
// Output: "Computed function called!"
// Output: "John Doe"
// Second access - the function does not run!
console.log(fullName.get());
// Output: "John Doe"
// Third access - still cached
console.log(fullName.get());
// Output: "John Doe"
// Now change a dependency - this invalidates the cache
firstName.set("Jane");
// Next access triggers re-computation because firstName changed
console.log(fullName.get());
// Output: "Computed function called!"
// Output: "Jane Doe"
// Accessing again - cached again until next dependency change
console.log(fullName.get());
// Output: "Jane Doe" (no "Computing..." message)
When a computed signal runs, it sets itself as the "currently computing" signal. Any state signals accessed during this time automatically register the computed as a dependent.
// When this computed runs:
const computed = new Signal.Computed(() => {
return state1.get() + state2.get(); // Both state1 and state2 track this computed
});
Computed signals are lazy - they only recalculate when someone actually reads their value, even if dependencies changed earlier.
state.set(newValue); // Marks computeds as "stale" but doesn't run them
// ... other code ...
const result = computed.get(); // NOW the computation runs
This educational version omits some optimizations found in production signals:
- No performance optimizations (prioritizes clarity)
- No advanced scheduling (basic effect implementation)
- No memory optimizations (uses simple data structures)
- No async support (synchronous only)
- Simplified error handling (basic error propagation)