Skip to content

Signals

In rvx, a Signal is an object which holds an arbitrary value and keeps track of things that have accessed that value.

To create a signal, you can use the Signal constructor or the $ shorthand:

import { $, Signal } from "rvx";

// using the constructor:
const count = new Signal(42);
// or using the shorthand:
const count = $(42);
import { $, Signal } from "./rvx.js";

// using the constructor:
const count = new Signal(42);
// or using the shorthand:
const count = $(42);

The current value can be accessed or updated using the value property:

count.value++;

To deeply change a value and then notify the signal observers, use the update function:

const items = $(["a", "b"]);

items.update(items => {
    items.push("c");
});

Signals can also be controlled manually:

// Pretend that count was accessed:
count.access();

// Pretend that count has changed:
count.notify();

Equality

By default, setting a signal's value property only notifies it's observers if the value is not the same.

const count = $(42);
// This does nothing since the value is already 42:
count.value = 42;

Expressions

In rvx, an Expression can be a static value, a signal or a function that accesses signals.

// A static value:
42;
// A signal itself:
$(42);
// A function that accesses signals:
() => a.value * b.value;

watch

Watch an expression until the current lifecycle is disposed.

import { watch } from "rvx";

watch(count, value => {
    console.log("Count:", value);
});
import { watch } from "./rvx.js";

watch(count, value => {
    console.log("Count:", value);
});
  • The current context is available in both the expression and effect.
  • Evaluation is stopped when the current lifecycle is disposed.
  • Teardown hooks are called before a signal update is processed or when the current lifecycle is disposed.

The second effect parameter can be omitted if you want or need to run side effects inside the expression:

import { watch, get } from "rvx";

watch(() => {
    console.log("Count:", get(count));
});
import { watch, get } from "./rvx.js";

watch(() => {
    console.log("Count:", get(count));
});

Tip

You can use untrack to ignore specific signal accesses or isolate all side effects of arbitrary code.

watchUpdates

This is the same as watch, but the initial value is returned instead of being passed to the effect.

import { watchUpdates } from "rvx";

const initialCount = watchUpdates(count, value => {
    console.log("Count:", value);
});
import { watchUpdates } from "./rvx.js";

const initialCount = watchUpdates(count, value => {
    console.log("Count:", value);
});

batch

Signal updates are always processed immediately. The batch function can be used to deduplicate and defer updates until the batch callback finishes:

import { batch } from "rvx";

const a = $(1);
const b = $(2);

batch(() => {
    a.value++;
    b.value++;
});
import { batch } from "./rvx.js";

const a = $(1);
const b = $(2);

batch(() => {
    a.value++;
    b.value++;
});

If updates from a batch cause immediate recursive side effects, these are also processed as part of the batch.

memo

Run a function and re-run when any accessed signals are updated.

This is the same as watch except that it returns a function to reactively access the latest expression result.

import { memo } from "rvx";

const getValue = memo(() => a.value * b.value);
import { memo } from "./rvx.js";

const getValue = memo(() => a.value * b.value);
  • The current context is available in the expression.
  • Execution is stopped when the current lifecycle is disposed.
  • Teardown hooks from the callback are called when the current lifecycle is disposed or before the next call.

track & untrack

Signal accesses are tracked in expressions by default. You can use untrack to disable tracking during a function call or track to restore the default.

import { track, untrack } from "rvx";

watch(() => a.value * untrack(() => b.value), () => { ... });
import { track, untrack } from "./rvx.js";

watch(() => a.value * untrack(() => b.value), () => { ... });

get

Manually evaluate an expression of an unknown type.

import { get } from "rvx";

get(42); // 42
get(() => 42); // 42
get($(42)); // 42
import { get } from "./rvx.js";

get(42); // 42
get(() => 42); // 42
get($(42)); // 42

map

Map an expression value while preserving if the expression is static or not.

import { map } from "rvx";

// This immediately computes the value:
map(6, value => value * 7);

// This returns a function to compute the value:
map($(6), value => value * 7);
import { map } from "./rvx.js";

// This immediately computes the value:
map(6, value => value * 7);

// This returns a function to compute the value:
map($(6), value => value * 7);

trigger

Create an expression evaluator pipe that calls a function once when any accessed signals from the latest evaluated expression are updated.

When the lifecycle at which the pipe was created is disposed, the callback function will not be called anymore.

import { $, trigger } from "rvx";

// Create a new pipe that is bound to the current lifecycle:
const pipe = trigger(() => {
    console.log("Signal has been updated.");
});

const signal = $(42);

// Evaluating an expression through the pipe will track all signal accesses:
console.log(pipe(signal)); // 42
console.log(pipe(() => signal.value)); // 42

// This will trigger the callback:
signal.value = 77;
import { $, trigger } from "./rvx.js";

// Create a new pipe that is bound to the current lifecycle:
const pipe = trigger(() => {
    console.log("Signal has been updated.");
});

const signal = $(42);

// Evaluating an expression through the pipe will track all signal accesses:
console.log(pipe(signal)); // 42
console.log(pipe(() => signal.value)); // 42

// This will trigger the callback:
signal.value = 77;

It is guaranteed that the function is called before any other observers like watch or watchUpdates are notified. This can be used to run side effects like clearing a cache before an expression is re-evaluated:

import { $, trigger, watch } from "rvx";

const pipe = trigger(() => {
    console.log("Signal has been updated.");
});

const signal = $(42);
watch(() => {
    console.log("Evaluating...");
    return pipe(signal);
}, value => {
    console.log("Value:", value);
});

signal.value = 77;
import { $, trigger, watch } from "./rvx.js";

const pipe = trigger(() => {
    console.log("Signal has been updated.");
});

const signal = $(42);
watch(() => {
    console.log("Evaluating...");
    return pipe(signal);
}, value => {
    console.log("Value:", value);
});

signal.value = 77;
Evaluating...
Value: 42
Signal has been updated.
Evaluating...
Value: 77

If pipes are nested, the callback for the most inner one is called first. In the example below, the callback for pipeB is called first:

import { $, trigger } from "rvx";

const pipeA = trigger(() => console.log("Pipe A"));
const pipeB = trigger(() => console.log("Pipe B"));

const signal = $(42);
pipeA(() => pipeB(signal)); // 42

signal.value = 77;
import { $, trigger } from "./rvx.js";

const pipeA = trigger(() => console.log("Pipe A"));
const pipeB = trigger(() => console.log("Pipe B"));

const signal = $(42);
pipeA(() => pipeB(signal)); // 42

signal.value = 77;

Trigger callbacks run isolated from signal access tracking and the current lifecycle.

Immediate Side Effects

By default, signal updates are processed immediately. If an update causes recursive side effects, they run in sequence instead.

import { $, watch } from "rvx";

const count = $(0);

watch(count, value => {
    console.group("Count:", value);
    if (value < 2) {
        count.value++;
        console.log("New count:", count.value);
    }
    console.groupEnd();
});

console.log("Final count:", count.value);
import { $, watch } from "./rvx.js";

const count = $(0);

watch(count, value => {
    console.group("Count:", value);
    if (value < 2) {
        count.value++;
        console.log("New count:", count.value);
    }
    console.groupEnd();
});

console.log("Final count:", count.value);
Count: 0
    New count: 1
Count: 1
    New count: 2
Count: 2
Final count: 2

Memory References

Observers like watch and trigger, signals and teardown hooks reference each other in the ways described below.

  • Observer registered teardown hooks reference their observers.
  • Observers reference all accessed signals from the latest expression until disposed.
  • Signals reference all current observers.

Warning

Not cleaning up unused observers by calling their teardown hooks can result in memory leaks and other undefined behavior.

function showNotification(message: unknown) {
    const view = mount(
        document.body,
        <div class="notification">
            <T id="notification-title" />
            {message}
        </div>
    );
    setTimeout(() => view.detach(), 3000);
}
function showNotification(message: unknown) {
    const view = mount(
        document.body,
        e("div").class("notification").append(
            T({ id: "notification-title" }),
            message,
        ),
    );
    setTimeout(() => view.detach(), 3000);
}

Assuming that the <T> component accesses some global signal with the current locale code to translate the specified key, calling the showNotification function will result in a memory leak, because the teardown hooks from observing that signal are never called.

In addition, calling this function in a context that did capture teardown hooks can result in unintended behavior.

This can be fixed by manually capturing teardown hooks and then using these to dispose the notification later:

function showNotification(message: unknown) {
    const dispose = capture(() => {
        mount(
            document.body,
            <div class="notification">
                <T id="notification-title" />
                {message}
            </div>
        );
    });
    setTimeout(dispose, 3000);
}
function showNotification(message: unknown) {
    const dispose = capture(() => {
        mount(
            document.body,
            e("div").class("notification").append(
                T({ id: "notification-title" }),
                message,
            ),
        );
    });
    setTimeout(dispose, 3000);
}

This prevents any signal related memory leaks and also isolates everything inside showNotification from the outside lifecycle context.

Tip

In a development or testing environment, you can set up leak detection to automatically detect leaked teardown hooks.

Troubleshooting

For signal based reactivity to work, the following is required:

  • The value in a signal must be replaced, or the signal must notify observers using notify or update.
  • The place where the value is used must be able to access the signal by calling a function.

Deep Updates

Signals don't automatically detect when values are deeply changed. They only detect when values are entirely replaced.

const counter = $({ count: 0 });
// This will not trigger any updates:
counter.value.count++;

When possible, you should wrap the inner values into signals:

const counter = { count: $(0) };
// Signals can also be deeply nested:
const counter = $({ count: $(0) });

When this isn't possible, you can use one of the following options:

// Use the update function:
counter.update(value => {
    value.count++;
});

// Replace the entire value:
counter.value = { count: 1 };

// Manually notify observers:
counter.value.count++;
counter.notify();

If you need deeply reactive objects, you can use the store API.

Static Values

The value of signals or expressions can always be used in a non reactive ways:

const count = $(0);

// This isn't reactive:
<>{count.value}</>;
<>{get(count)}</>;
const count = $(0);

// This isn't reactive:
count.value;
get(count);

For accesses to be reactive, you need to use a signal directly or access it's value in a function:

// This is now reactive:
<>{() => count.value}</>;
<>{() => get(count)}</>;

// Using the signal itself is also reactive:
<>{count}</>;
// This is now reactive:
() => count.value;
() => get(count);

// Using the signal itself is also reactive:
count;