SkillAgentSearch skills...

S

S.js - Simple, Clean, Fast Reactive Programming in Javascript

Install / Use

/learn @adamhaile/S
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

S.js

S.js is a small reactive programming library. It combines an automatic dependency graph with a synchronous execution engine. The goal is to make reactive programming simple, clean, and fast.

An S app consists of data signals and computations:

  • data signals are created with S.data(<value>). They are small containers for a piece of data that may change.

  • computations are created with S(() => <code>). They are kept up-to-date as data signals change.

Both kinds of signals are represented as small functions: call a signal to read its current value, pass a data signal a new value to update it.

Beyond these two, S has a handful of utilities for controlling what counts as a change and how S responds.

Features

Automatic Updates - When data signal(s) change, S automatically re-runs any computations which read the old values.

A Clear, Consistent Timeline - S apps advance through a series of discrete "ticks." In each tick, all signals are guaranteed to return up-to-date values, and state is immutable until the tick completes. This greatly simplifies the often difficult task of reasoning about how change flows through a reactive app.

Batched Updates - Multiple data signals can be changed in a single tick (aka "transactions").

Automatic Disposals - S computations can themselves create more computations, with the rule that "child" computations are disposed when their "parent" updates. This simple rule allows apps to be leak-free without the need for manual disposals.

A Quick Example

Start with the the world's smallest web app. It just sets the body of the page to the text "Hello, world!"

let greeting = "Hello",
    name = "world";

document.body.textContent = `${greeting}, ${name}!`;

Now let's change the name.

name = "reactivity";

The page is now out of date, since it still has the old name, "Hello, world!" It didn't react to the data change. So let's fix that with S's wrappers.

let greeting = S.data("Hello"),
    name = S.data("world");

S(() => document.body.textContent = `${greeting()}, ${name()}!`);

The wrappers return small functions, called signals, which are containers for values that change over time. We read the current value of a signal by calling it, and if it's a data signal, we can set its next value by passing it in.

name("reactivity");

S knows that we read the old value of name() when we set the page text, so it re-runs that computation now that name() has changed. The page now reads "Hello, reactivity!" Yay!

We've converted the plain code we started with into a small machine, able to detect and keep abreast of incoming changes. Our data signals define the kind of changes we might see, our computations how we respond to them.

For longer examples see:

API

Data Signals

S.data(<value>)

A data signal is a small container for a single value. It's where information and change enter the system. Read the current value of a data signal by calling it, set the next value by passing in a new one:

const name = S.data("sue");
name(); // returns "sue"
name("emily") // sets name() to "emily" and returns "emily"

Data signals define the granularity of change in your application. Depending on your needs, you may choose to make them fine-grained – containing only an atomic value like a string, number, etc – or coarse – an entire large object in a single data signal.

Note that when setting a data signal you are setting the next value: if you set a data signal in a context where time is frozen, like in an S.freeze() or a computation body, then your change will not take effect until time advances. This is because of S's unified global timeline of atomic instants: if your change took effect immediately, then there would be a before and after the change, breaking the instant in two:

const name = S.data("sue");
S.freeze(() => {
    name("mary"); // *schedules* next value of "mary" and returns "mary"
    name(); // still returns "sue"
});
name(); // now returns "mary";

Most of the time, you are setting a data signal at top level (outside a computation or freeze), so the system immediately advances to account for the change.

It is an error to schedule two different next values for a data signal (where "different" is determined by !==):

const name = S.data("sue");
S.freeze(() => {
    name("emily");
    name("emily"); // OK, "emily" === "emily"
    name("jane"); // EXCEPTION: conflicting changes: "emily" !== "jane"
});

Data signals created by S.data() always fire a change event when set, even if the new value is the same as the old:

const name = S.data("sue"),
    counter = S.on(name, c => c + 1, 0); // counts name() change events
counter(); // returns 1 to start
name("sue"); // fire three change events, all with same value
name("sue");
name("sue");
counter(); // now returns 4

S.value(<value>)

S.value() is identical to S.data() except that it does not fire a change event when set to the same value. It tells S "only the value of this data signal is important, not the set event."

const name = S.value("sue"),
    counter = S.on(name, c => c + 1, 0);
counter(); // returns 1 to start
name("sue"); // set to the same value
counter(); // still returns 1, name() value didn't change

The default comparator is ===, but you can pass in a custom one as a second parameter if something else is more appropriate:

const user = S.value(sue, (a, b) => a.userId === b.userId);

Computations

S(() => <code>)

A computation is a "live" piece of code which S will re-run as needed when data signals change.

S runs the supplied function immediately, and as it runs, S automatically monitors any signals that it reads. To S, your function looks like:

S(() => {
        ... foo() ...
    ... bar() ...
       ... bleck() ... zog() ...
});

If any of those signals change, S schedules the computation to be re-run.

The referenced signals don't need to be in the lexical body of the function: they might be in a function called from your computation. All that matters is that evaluating the computation caused them to be read.

Similarly, signals that are in the body of the function but aren't read due to conditional branches aren't recorded. This is true even if prior executions went down a different branch and did read them: only the last run matters, because only those signals were involved in creating the current value.

If some of those signals are computations, S guarantees that they will always return a current value. You'll never get a "stale" value, one that is affected by an upstream change but hasn't been updated yet. To your function, the world is always temporally consistent.

S also guarantees that, no matter how many changed data signals are upstream of your function and no matter how many paths there are through the graph from them to your function, it will only run once per update.

Together, these two qualities make S "glitchless" (named after this scene from The Matrix): you'll never experience the same moment twice (redundant updates of a computation) or two moments at once (stale and current values in the same update).

Not Just Pure Functions

The functions passed to S don't have to be pure (i.e. no side-effects). For instance, we can log all changes to name() like so:

S(() => console.log(name());

Every time name() changes, this will re-run and re-log the value to the console.

In a sense, this expands the idea of what the 'value' of the computation is to include the side-effects it produces.

Tip: S.cleanup() and S.on() can be useful utilities when writing computations that perform side-effects. The first can help make your computations idempotent (a nice property for effectful computations), while the second can help make it clear when they run.

... And Maybe Not Pure Functions

Ask yourself: if a pure computation isn't read in your app, does it need to run?

The S() constructor is symmetric: it takes a paramless function that returns a value, and it returns a paramless function that returns the same value. The only difference is when that function runs. Without S(), it runs once per call. With S(), it runs once per change.

While S computations are designed to have minimal overhead, the cost isn't zero, and it may be faster and/or clearer to leave some lambdas plain. Any computations which call them will "see through" to any signals they reference, so they'll still be reactive.

Some rules of thumb:

  1. If your function is O(1) and simple enough that its overhead is comparable to that of S's bookkeeping, leave it a plain lambda. An example would be a fullName() function that just concats firstName() and lastName() data signals.

  2. If your function is attached to an object that outlives its parent computation, lean towards a plain lambda, to avoid the need for manual lifecycle management (see S.root()).

  3. On the other hand, if your function's complexity is O(N) (scales with the amount of data), lean towards a computation, unless you're sure that it will only be called a constant number of times per update cycle.

Computations Creating Computations

S allows computations to expand the system with more computations.

const isLogging = S.value(true);
S(() => {
    if (isLogging()) {
        S(() => console.log(foo()));
        S(() => console.log(bar()));
        S(() => console.log(bleck()));
    }
});

In this e

Related Skills

View on GitHub
GitHub Stars1.4k
CategoryDevelopment
Updated3d ago
Forks74

Languages

JavaScript

Security Score

95/100

Audited on Mar 29, 2026

No findings