S.js
S.js is a small library for performing simple, clean, fast reactive programming in Javascript. It aims for a simple mental model, a clean and expressive syntax, and fast execution.
In plain terms, S helps you keep things up-to-date in your program. S programs work like a spreadsheet: when data changes, S automatically updates downstream computations.
Here's a tiny example:
var a = S.data(1),
b = S.data(2),
c = S(() => a() + b()),
d = S(() => c() * a());
a(3);
b(4);
S.freeze(() => {
a(5);
b(6);
});
The timeline on the right shows how the values evolve at each instant. Initially (time t0), c()
and d()
are 3, but when a()
changes to 3 (t1), they become 5 and 15. Ditto for t2 and t3. Every time a()
or b()
changes, S re-evaluates c()
and d()
to make sure they stay consistent.
To achieve this behavior, static data and computations must be converted to signals, which is a reactive term for a value that changes over time. S data signals are constructed by S.data(<value>)
and computations by S(() => <code>)
. Both return closures: call a signal to read its current value; pass a data signal a new value to change it.
When an S computation runs, S records what signals it references, thereby creating a live dependency graph of running code. When data changes, S uses that graph to figure out what needs to be updated and in what order.
S has a small API. The example above shows S.freeze()
, which aggregates multiple changes into a single step (t3). The full API is listed below.
S Features
S maintains a few useful behaviors while it runs. These features are designed to make it easier to reason about reactive programming:
Automatic Dependencies - No manual (un)subscription to change events. Dependencies in S are automatic and exact.
Guaranteed Currency - No need to worry about how change propagates through the system. S insures that signals always return current and updated values.
Exact Updates - No missed or redundant updates. Computations run exactly once per upstream change event.
A Unified Global Timeline - No confusing nested or overlapping mutations from different sections of code. S apps advance through a series of discrete "instants" during which state is immutable.
Self-Extensible - Computations can extend the system by creating new "child" computations.
Automatic Disposals - No manual disposal of stale computations. "Child" computations are disposed automatically when their "parent" updates.
For advanced cases, S provides capabilities for dealing with self-mutating code:
Multi-Step Updates - Computations can set data signals during their execution. These changes don't take effect until the current "instant" finishes, resulting in a multi-step update.
Partitionable Time - Multi-step updates can run on a 'subclock,' meaning that surrounding code will only respond to final, at-rest values, not intermediate ones.
An Example: TodoMVC in S (plus friends)
What else, right? S is just a core library for dealing with change; it takes more to build an application. This example uses Surplus.js, aka "S plus" a few companion libraries. Most notably, it uses Surplus' JSX preprocessor for embedded DOM construction.
var Todo = t => ({
title: S.data(t.title),
done: S.data(t.done)
}),
todos = SArray([]),
newTitle = S.data(""),
addTodo = () => {
todos.push(Todo({ title: newTitle(), done: false }));
newTitle("");
},
view = S.root(() =>
<div> // declarative main view
<input type="text" {...data(newTitle)}/>
<a onClick={addTodo}>+</a>
{todos.map(todo => // insert todo views
<div>
<input type="checkbox" {...data(todo.done)}/>
<input type="text" {...data(todo.title)}/>
<a onClick={() => todos.remove(todo)}>×</a>
</div>)}
</div>);
document.body.appendChild(view);
Some things to note:
-
There's no code to handle updating the application. Other than a liberal sprinkling of ()'s
, this could be static code. In the lingo, S enables declarative programming, where we focus on defining how things should be and S handles updating the app from one state to the next as our data changes.
-
The Surplus library leverages S computations to construct the dynamic parts of the view (the '{ ... }' expressions). Whenever our data changes, S updates the affected parts of the DOM automatically.
-
S handles updates in as efficient a manner as possible: Surplus apps generally place at or near the top of the various web framework benchmarks (ToDoMVC, dbmonster, js-framework-benchmark, etc).
Reactive programs also have the benefit of an open structure that enables extensibility. For instance, we can add localStorage persistence with no changes to the code above and only a handful of new lines:
if (localStorage.todos)
todos(JSON.parse(localStorage.todos).map(Todo));
S(() =>
localStorage.todos = JSON.stringify(todos()));
API
Constructors
S.root(dispose => <code>)
Computations created by <code>
live until dispose
is called. S will log an error if you try to construct a computation that is not under a root or parent computation.
S.data(<value>)
Construct a data signal whose initial value is <value>
.
S(() => <code>)
Construct a computation whose value is the result of the given <code>
.
S(val => <code>, <seed>)
Construct a reducing computation, whose new value is derived from the last one, staring with <seed>
.
S.on(<signal>, val => <code>, <seed>, <onchanges>)
Statically declare a computation's dependencies, rather than relying on S's automatic dependency detection.
<seed>
is optional, with default undefined
.
<onchanges>
is optional and defaults to false
. If <onchanges>
is true, then the initial run is skipped (i.e. computation starts with value <seed>
and doesn't run <code>
until a change occurs).
<signal>
may be an array, in which case dependencies are created for each signal in the array.
Behavior
S.freeze(() => <code>)
Run <code>
, but hold any data changes it produces in a pending state until it completes, at which point they all run as a single update. If called within a computation, the system is already frozen, so freeze
is inert. Returns value of <code>
.
S.sample(<signal>)
Sample the current value of <signal>
but don't create a dependency on it.
S.cleanup(final => <code>)
Run the given function just before the enclosing computation updates or is disposed. The function receives a boolean parameter indicating whether this is the "final" cleanup, with true
meaning the computation is being disposed, false
it is being updated.
S.cleanup() is used to free external resources which a computation may have claimed, like DOM event subscriptions. Computations can register as many cleanup handlers as needed, usually adjacent to where the resources are claimed.
S.subclock(() => <code>)
Run computations and data signals created by <code>
on a subclock, meaning that they don't just run but run to completion before surrounding code reads them.
© 2017 Adam Haile, adam.haile@gmail.com. MIT License.