Skip to content

re-js/mobx-signals

Repository files navigation

MobX Signals Implementation

npm i mobx-signals

This directory contains the code for MobX's reactive primitive, an implementation of the "signal" concept. A signal is a value which is "reactive", meaning it can notify interested consumers when it changes. There are many different implementations of this concept, with different designs:

This document describes the MobX-based implementation of the signal pattern. Basically, it totally equals Angular implementation, with an additional few MobX methods.

Conceptual surface

Signals are zero-argument functions (() => T). When executed, they return the current value of the signal. Executing signals does not trigger side effects, though it may lazily recompute intermediate values (lazy memoization).

Particular contexts (such as template expressions) can be reactive. In such contexts, executing a signal will return the value, but also register the signal as a dependency of the context in question. The context's owner will then be notified if any of its signal dependencies produces a new value (usually, this results in the re-execution of those expressions to consume the new values).

This context and getter function mechanism allows for signal dependencies of a context to be tracked automatically and implicitly. Users do not need to declare arrays of dependencies, nor does the set of dependencies of a particular context need to remain static across executions.

Writable signals: signal()

The signal() function produces a specific type of signal known as a WritableSignal. In addition to being a getter function, WritableSignals have an additional API for changing the value of the signal (along with notifying any dependents of the change). These include the .set operation for replacing the signal value, .update for deriving a new value, and .mutate for performing internal mutation of the current value. These are exposed as functions on the signal getter itself.

const counter = signal(0);

counter.set(2);
counter.update(count => count + 1);

The signal value can be also updated in-place, using the dedicated .mutate method:

const todoList = signal<Todo[]>([]);

todoList.mutate(list => {
  list.push({title: 'One more task', completed: false});
});

Equality

The signal creation function one can, optionally, specify an equality comparator function. The comparator is used to decide whether the new supplied value is the same, or different, as compared to the current signal’s value.

If the equality function determines that 2 values are equal it will:

  • block update of signal’s value;
  • skip change propagation.

Declarative derived values: computed()

computed() creates a memoizing signal, which calculates its value from the values of some number of input signals.

const counter = signal(0);

// Automatically updates when `counter` changes:
const isEven = computed(() => counter() % 2 === 0);

Because the calculation function used to create the computed is executed in a reactive context, any signals read by that calculation will be tracked as dependencies, and the value of the computed signal recalculated whenever any of those dependencies changes.

Similarly to signals, the computed can (optionally) specify an equality comparator function.

Side effects: effect()

effect() schedules and runs a side-effectful function inside a reactive context. Signal dependencies of this function are captured, and the side effect is re-executed whenever any of its dependencies produces a new value.

const counter = signal(0);
effect(() => console.log('The counter is:', counter()));
// The counter is: 0

counter.set(1);
// The counter is: 1

Decorators 🚀

Many existing codebases use decorators, and a lot of the documentation and tutorial material online uses them as well.

import { signal, computed } from "mobx-signals";

class Todo {
    @signal title = "";
    @signal finished = false;

    toggle() {
        this.finished = !this.finished;
    }
}

class TodoList {
    @signal todos = [];

    @computed
    get unfinishedTodo() {
        return this.todos.filter(todo => !todo.finished);
    }
}

These decorators do not need "makeObservable" and work in a different way, more similar to previous versions of MobX.

Additional APIs

The MobX-based library provides an excellent opportunity to export some more handy methods.

Reactions: reaction()

reaction(() => value, (value, previousValue) => void, options?)

reaction is like effect, but gives more fine grained control on which signals will be tracked. It takes two functions: the first, data function, is tracked and returns the data that is used as input for the second, effect function. It is important to note that the side effect only reacts to data that was accessed in the data function, which might be less than the data that is actually used in the effect function.

The typical pattern is that you produce the things you need in your side effect in the data function, and in that way control more precisely when the effect triggers. By default, the result of the data function has to change in order for the effect function to be triggered. Unlike effect, the side effect won't run once when initialized, but only after the data expression returns a new value for the first time.

Wait for the condition once: when()

when(predicate: () => boolean, effect?: () => void, options?)
when(predicate: () => boolean, options?): Promise

when observes and runs the given predicate function until it returns true. Once that happens, the given effect function is executed and the autorunner is disposed.

The when function returns a disposer, allowing you to cancel it manually, unless you don't pass in a second effect function, in which case it returns a Promise.

Promise of await when(...)

If no effect function is provided, when returns a Promise. This combines nicely with async / await to let you wait for changes in observable state.

async function() {
    await when(() => that.isVisible)
}

To cancel when prematurely, it is possible to call .cancel() on the promise returned by itself.

untracked()

untracked(body: () => T): T

Runs a piece of code without establishing observers.

transaction()

transaction(body: () => T): T

Used to batch a bunch of updates without running any reactions until the end of the transaction.

Like untracked method, It takes a single, parameterless function as an argument, and returns any value that was returned by it. Note that It runs completely synchronously and can be nested. Only after completing the outermost transaction, the pending reactions will be run.

"Glitch Free" property

Consider the following setup:

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

When the effect is first created, it will print "0 is even", as expected, and record that both counter and evenOrOdd are dependencies of the logging effect.

When counter is set to 1, this invalidates both evenOrOdd and the logging effect. If counter.set() iterated through the dependencies of counter and triggered the logging effect first, before notifying evenOrOdd of the change, however, we might observe the inconsistent logging statement "1 is even". Eventually evenOrOdd would be notified, which would trigger the logging effect again, logging the correct statement "1 is odd".

In this situation, the logging effect's observation of the inconsistent state "1 is even" is known as a glitch. A major goal of reactive system design is to prevent such intermediate states from ever being observed, and ensure glitch-free execution.

Dynamic Dependency Tracking

When a reactive context operation (for example, an effect's side effect function) is executed, the signals that it reads are tracked as dependencies. However, this may not be the same set of signals from one execution to the next. For example, this computed signal:

const dynamic = computed(() => useA() ? dataA() : dataB());

reads either dataA or dataB depending on the value of the useA signal. At any given point, it will have a dependency set of either [useA, dataA] or [useA, dataB], and it can never depend on dataA and dataB at the same time.

The potential dependencies of a reactive context are unbounded. Signals may be stored in variables or other data structures and swapped out with other signals from time to time. Thus, the signals implementation must deal with potential changes in the set of dependencies of a consumer on each execution.

A naive approach would be to simply remove all old dependency edges before re-executing the reactive operation, or to mark them all as stale beforehand and remove the ones that don't get read. This is conceptually simple, but computationally heavy, especially for reactive contexts that have a largely unchanging set of dependencies.

Equality Semantics

Producers may lazily produce their value (such as a computed which only recalculates its value when pulled). However, a producer may also choose to apply an equality check to the values that it produces, and determine that the newly computed value is "equal" semantically to the previous. In this case, consumers which depend on that value should not be re-executed. For example, the following effect:

const counter = signal(0);
const isEven = computed(() => counter() % 2 === 0);
effect(() => console.log(isEven() ? 'even!' : 'odd!'));

should run if counter is updated to 1 as the value of isEven switches from true to false. But if counter is then set to 3, isEven will recompute the same value: false. Therefore the logging effect should not run.

This is a tricky property to guarantee in our implementation because values are not recomputed during the push phase of change propagation. isEven is invalidated when counter is changed, which causes the logging effect to also be invalidated and scheduled. Naively, isEven wouldn't be recomputed until the logging effect actually runs and attempts to read its value, which is too late to notice that it didn't need to run at all.

Install

npm i mobx-signals

Enjoy your code!

About

MobX Signals Implementation

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published