Module @conterra/reactivity-core - v0.5.0

A framework agnostic library for building reactive applications.

@conterra/reactivity-core NPM Version

UI framework independent reactivity library with support for all kinds of values.

Click here to visit the rendered API Documentation.

import { reactive, computed, watchValue } from "@conterra/reactivity-core";

const firstName = reactive("John");
const lastName = reactive("Doe");
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

watchValue(
() => fullName.value,
(fullName) => {
console.log(fullName);
},
{
immediate: true
}
);

firstName.value = "Jane";

Signals are reactive "boxes" that contain a value that may change at any time. They can be easily watched for changes by, for example, registering a callback using watch().

Signals may also be composed via computed signals, whose values are derived from other signals and are then automatically kept up to date. They can also be used in classes (or plain objects) for organization based on concern or privacy.

The following snippet creates a signal r through the function reactive<T>() that initially holds the value "foo". reactive<T>() is one of the most basic forms to construct a signal:

import { reactive } from "@conterra/reactivity-core";

const r = reactive("foo");
console.log(r.value); // prints "foo"

r.value = "bar";
console.log(r.value); // prints "bar"

The current value of a signal can be accessed by reading the property .value. If you have a writable signal (which is the case for signals returned by reactive<T>()), you can update the inner value by assigning to the property .value.

Whenever the value of a signal changes, any watcher (a party interested in the current value) will automatically be notified. The effect() function is one way to track one (or many) signals:

import { reactive, effect } from "@conterra/reactivity-core";

const r = reactive("foo");

// Effect callback is executed once; prints "foo" immediately
effect(() => {
// This access to `r.value` is tracked by the effect.
// When the signal's value changes, the effect is executed again.
console.log(r.value);
});

// Triggers another execution of the effect; prints "bar" now.
r.value = "bar";

effect(callback) works like this:

  • First, it will execute the given callback immediately.
  • During the execution, it tracks all signals whose values were accessed by callback. This also works indirectly, for example if you call one or more functions which internally use signals.
  • When any of those signals are updated, the effect will re-execute callback.
  • These re-executions will happen indefinitely: either until the signals no longer change or until the effect has been destroyed. Effects can be destroyed by using the object returned by effect() (see Cleanup).
  • For an alternative API that doesn't trigger on every change, see watch().

Signals can be composed by deriving values from them via computed(). computed() takes a callback function as its argument. That function can access any number of signals and should then return some JavaScript value. The following example creates a computed signal that always returns twice the original age.

import { reactive, computed } from "@conterra/reactivity-core";

const age = reactive(21);
const doubleAge = computed(() => age.value * 2);

console.log(doubleAge.value); // 42
age.value = 22;
console.log(doubleAge.value); // 44

Computed signals can be watched (e.g. via effect()) like any other signal:

import { reactive, computed, effect } from "@conterra/reactivity-core";

const age = reactive(21);
const doubleAge = computed(() => age.value * 2);

// prints 42
effect(() => {
console.log(doubleAge.value);
});

// re-executes effect, which prints 44
age.value = 22;

Computed signals only re-compute their value (by invoking the callback function) when any of their dependencies have changed. For as long as nothing has changed, the current value will be cached. This can make even complex computed signals very efficient.

Note that the callback function for a computed signal should be stateless: it is supposed to compute a value (possibly very often), and it should not change the state of the application while doing so.

You can use signals in your classes (or single objects) to implement reactive objects. For example:

import { reactive } from "@conterra/reactivity-core";

class Person {
// Could be private or public
_name = reactive("");

get name() {
return this._name.value;
}

set name(value) {
// Reactive write -> watches that used the `name` getter are notified.
// We could use this setter (which could also be a normal method) to enforce preconditions.
this._name.value = value;
}
}

Instances of person are now reactive, since their state is actually stored in signals:

import { effect } from "@conterra/reactivity-core";

// Person class from previous example
const p = new Person();
p.name = "E. Example";

// Prints "E. Example"
effect(() => {
console.log(p.name);
});

// Triggers effect again; prints "T. Test"
p.name = "T. Test";

You can also provide computed values or accessor methods in your class:

import { reactive, computed } from "@conterra/reactivity-core";

// In this example, first name and last name can only be written to.
// Only the combined full name is available to users of the class.
class Person {
_firstName = reactive("John");
_lastName = reactive("Doe");
_fullName = computed(() => `${this._firstName.value} ${this._lastName.value}`);

setFirstName(name: string) {
this._firstName.value = name;
}

setLastName(name: string) {
this._lastName.value = name;
}

getFullName() {
return this._fullName.value;
}
}

We provide two different APIs to run code when reactive values change. The simpler one is effect effect():

import { reactive, effect } from "@conterra/reactivity-core";

const r1 = reactive(0);
const r2 = reactive(1);
const r3 = reactive(2);

// Will run whenever _any_ of the given signals changed,
// even if the sum turns out to be the same.
effect(() => {
const sum = r1.value + r2.value + r3.value;
console.log(sum);
});

If your effect callbacks become more complex, it may be difficult to control which signals are ultimately used. This can result in your effect running too often, because you're really only interested in some changes and not all of them.

In that case, you can use watchValue() (or one of the other watch variants) to have more fine grained control:

import { reactive, watchValue } from "@conterra/reactivity-core";

const r1 = reactive(0);
const r2 = reactive(1);
const r3 = reactive(2);

watchValue(
// (1)
() => {
const sum = r1.value + r2.value + r3.value;
return sum;
},
// (2)
(sum) => {
console.log(sum);
},
// (3)
{ immediate: true }
);

watchValue() takes two functions and one (optional) options object:

  • (1): The selector function. This function's body is tracked (like in effect()) and all its reactive dependencies are recorded. The function must return the value you want to watch and it should not have any side effects.
  • (2): The callback function. This function is called whenever the selector function returned a different value, and it receives that value as its first argument. The callback itself is not reactive and it may trigger arbitrary side effects.
  • (3): By default, the callback function will only be invoked after the watched value changed at least once. By specifying immediate: true, the callback will also run for the initial value.

In this example, the callback function will only re-run when the computed sum truly changed.

The callback function of watchValue() can access the previous value via its second argument:

import { reactive, watchValue } from "@conterra/reactivity-core";

const counter = reactive(0);
watchValue(
() => counter.value,
(count, oldCount) => {
console.log(count, oldCount);
}
);

counter.value += 1;
// Prints 1 0

Note that the second argument will be undefined for the first execution if immediate: true has been set (because there is no previous value).

You can return a function from effect and watch callbacks. This function will be invoked before the effect or watch is triggered again, or if the effect / watch is being destroyed.

You can use this function to undo or cancel an action started by your callback.

The following example fetches the user details for a given user id whenever that id changes:

import { reactive, effect, watchValue } from "@conterra/reactivity-core";

const userId = reactive("test-1");

// Fetch user details whenever the user id changes.
// The cleanup function cancels the previous job if it's still running.
effect(() => {
const controller = new AbortController();
const id = userId.value;
fetchUserDetails(id, controller.signal);
return () => {
controller.abort();
};
});

// Same thing, using watchValue():
watchValue(
() => userId.value,
(id) => {
const controller = new AbortController();
fetchUserDetails(id, controller.signal);
return () => {
controller.abort();
};
},
{ immediate: true }
);

// Trigger watch and effect
userId.value = "test-2";

async function fetchUserDetails(id: string, signal: AbortSignal): Promise<void> {
// ... would do a network request
console.log("fetch user", id);
}

The following table provides a quick overview of the different variants of effect and watch:

NOTE: In most circumstances, watchValue, watch or effect are the right choice. The sync* variants are useful when you need to run the callback immediately. For more details, see Sync vs async effect / watch.

Function Kind of values Callback condition Callback delay
effect N/A After any used signal changes. Slight delay.
watchValue Single value. After the watched value changed. Slight delay.
watch Multiple values (via array with shallow equality). After one ore more of the watched values changed. Slight delay.
syncEffect N/A After any used signal changed. No delay.
syncWatchValue Single value. After the watched value changed. No delay.
syncWatch Multiple values (via array with shallow equality). After one ore more of the watched values changed. No delay.

Note that watchValue and watch are almost the same. watch supports watching multiple values at once directly (but forces you to return an array) while watchValue only supports a single value. In truth, only their default equal functions are different: watchValue uses Object.is while watch uses shallow array equality.

Up to this point, examples have used primitive values such as strings or integers. Signals support any kind of value, for example:

import { reactive, watchValue } from "@conterra/reactivity-core";

const currentUser = reactive({
name: "User 1"
});

watchValue(
() => currentUser.value,
(user) => {
console.log(user.name);
},
{ immediate: true }
);

// Assignment to a signal's `.value` is reactive
currentUser.value = { name: "User 2" };

You should keep in mind that, by default, change detection is based on JavaScript's default comparison (i.e. Object.is). This means that objects or arrays (or any other reference type) may trigger changes even if their contents are equivalent (equal content but different identity). For example, the following change would trigger the watch() of the previous example, even though the name is the same:

// new object and thus a change
currentUser.value = { name: "User 1" };

For this reason, reactive and computed allow you to supply a custom equality function. This allows you to ignore certain updates by specifying that a value is equal to another value:

import { reactive, watchValue } from "@conterra/reactivity-core";

const currentUser = reactive(
{
name: "User 1"
},
{
equal: (u1, u2) => u1.name === u2.name
}
);

watchValue(
() => currentUser.value,
(user) => {
console.log(user.name);
},
{ immediate: true }
);

// Assignment is ignored because the name is the same.
currentUser.value = { name: "User 1" };

When you only need custom equality rules for a single watch, you can also use its equal option directly:

import { reactive, watchValue } from "@conterra/reactivity-core";

// No custom equality here.
const currentUser = reactive({
name: "User 1"
});

watchValue(
() => currentUser.value,
(user) => {
console.log(user.name);
},
{
immediate: true,
// Custom equality directly for the watch callback.
equal: (prev, next) => {
return prev.name === next.name;
}
}
);

// Assignment is ignored because the name is the same.
currentUser.value = { name: "User 1" };

As mentioned above, signals support any kind of value. This means that you can easily wrap an object, an array, or any other kind of container (e.g. Map/Set) in a signal. However, you will only be notified when the object (or array) changes, and not when its content does. In other words, deep reactivity is not support for "normal" JavaScript values.

At this point, we can recommend two approaches, based on your requirements.

This approach can be convenient for small collections or collections that don't update very often. Essentially, instead of updating the content of an collection, you replace the entire collection with an updated one:

import { effect, reactive } from "@conterra/reactivity-core";

const authors = reactive<string[]>(["Tolkien", "Grisham"]);
effect(() => {
console.log(authors.value);
});

function addAuthor(name: string) {
// Replace the array instead of updating it in place.
// This way, we can use a normal signal for reactivity.
authors.value = authors.value.concat(name);
}

addAuthor("King");

We implemented a few classes to make working with reactive collection easier, see Reactive Collections.

The previous example could also be written as:

import { effect, reactiveArray } from "@conterra/reactivity-core";

// NOTE: not a normal array (but mostly API-compatible).
const authors = reactiveArray(["Tolkien", "Grisham"]);
effect(() => {
console.log(authors.getItems());
});

function addAuthor(name: string) {
authors.push(name);
}

addAuthor("King");

Both effect() and watch() return a CleanupHandle to stop watching for changes:

const h1 = effect(/* ... */);
const h2 = watch(/* ... */);

// When you are no longer interested in changes:
h1.destroy();
h2.destroy();

When a watcher is not cleaned up properly, it will continue to execute (possibly forever). This leads to unintended side effects, unnecessary memory consumption and waste of computational power.

This package provides a set of collection classes to simplify working with complex values.

The ReactiveArray<T> behaves largely like a normal Array<T>. Most standard methods have been reimplemented with support for reactivity (new methods can be added on demand).

The only major difference is that one cannot use the [] operator. Users must use the array.get(index) and array.set(index, value) methods instead.

Example:

import { effect, reactiveArray } from "@conterra/reactivity-core";

// Optionally accepts initial content
const array = reactiveArray<number>();

// Prints undefined since the array is initially empty
effect(() => {
console.log(array.get(0));
});

array.push(1); // effect prints 1

// later
array.set(0, 123); // effect prints 123

The ReactiveSet<T> can be used as substitute for the standard Set<T>.

Example:

import { effect, reactiveSet } from "@conterra/reactivity-core";

// Optionally accepts initial content
const set = reactiveSet<number>();

// Prints 0 since the set is initially empty
effect(() => {
console.log(set.size);
});

set.add(123); // effect prints 1

The ReactiveMap<T> can be used as a substitute for the standard Map<T>.

Example:

import { effect, reactiveMap } from "@conterra/reactivity-core";

// Optionally accepts initial content
const map = reactiveMap<string, string>();

// Prints undefined since the map is initially empty
effect(() => {
console.log(map.get("foo"));
});

map.set("foo", "bar"); // effect prints "bar"

With the basic building blocks like reactive and computed you are able to create reactive objects. For example, you can create a Person class having a first name, a last name and computed property computing the full name, whenever first or last name changes. Instances of that class are reactive objects.

The reactivity API helps you to create simple reactive objects by providing a function called reactiveStruct.

For example, to create a person with reactiveStruct proceed as follows:

import { reactiveStruct } from "@conterra/reactivity-core";

// declare a type for the reactive object
interface Person {
firstName: string;
lastName: string;
}

// define a class like PersonClass
const PersonClass = reactiveStruct<Person>().define({
firstName: {}, // default options (reactive and writable)
lastName: { writable: false } // read-only
});

// create a new reactive instance
const person = new PersonClass({
firstName: "John",
lastName: "Doe"
});

// compute the full name
const fullName = computed(() => `${person.firstName} ${person.lastName}`);
console.log(fullName.value); // John Doe

person.firstName = "Jane";
console.log(fullName.value); // Jane Doe

The define function can be used to

  • make properties read-only
  • declare non-reactive properties
  • create computed properties
  • add methods to the reactive object

The following example shows declaring an extended Person:

import { reactiveStruct } from "@conterra/reactivity-core";

interface Person {
firstName: string;
lastName: string;
fullName: string; // will be a computed property
printName(): void; // a method printing the full name
}

const PersonClass = reactiveStruct<Person>().define({
firstName: {},
lastName: { writable: false },
fullName: {
compute() {
// executed whenever first or last name changes
return `${this.firstName} ${this.lastName}`;
}
},
printName: {
method() {
// always prints the current full name
console.log(`My name is ${this.fullName}`);
}
}
});

// create a new reactive instance
const person = new PersonClass({
firstName: "John",
lastName: "Doe"
});
person.printName(); // My name is John Doe
person.firstName = "Jane";
person.printName(); // My name is Jane Doe

Reactive structs are designed to help implement very simple classes: you can think of reactive structs as objects having reactive properties, computed properties and methods. They are not designed to replace every usage of the class keyword. For example, they do not support base classes or private properties.

If you need an advanced class, we recommend writing it yourself using standard JavaScript / TypeScript means. You can still (if needed) use a reactive struct internally, or you can use manual signals instead.

This reactivity system does automatically integrate with other ways to manage state (e.g. event based systems, third party reactivity systems). However, we do provide facilities to easily integrate "external" state yourself using the external signal.

To use external, you must implement two functionalities:

  1. A function to compute the current value of the external state. This is very similar to the way computed signals work, but it is not automatically reactive.
  2. You must subscribe to changes of the external state (through whatever appropriate means) and .trigger() the external signal. This tells our reactivity system that the current value is no longer up-to-date.

Example:

import { effect, external } from "@conterra/reactivity-core";

// An abort signal is a value that may be `aborted` through its controller.
// It provides both the `aborted` property (the current state) and a simple event that fires when that state changes.
// We use these facilities to provide a reactive boolean that accurately reflects the current state.
const controller = new AbortController();
const signal = controller.signal;

// boolean signal that tracks the aborted state.
// calls 'trigger()` on the signal when the signal is aborted.
const isAborted = external(() => signal.aborted);
signal.addEventListener("abort", isAborted.trigger);
// later, don't forget to unregister the event handler:
// signal.removeEventListener("abort", isAborted.trigger);

effect(() => {
console.log("is aborted:", isAborted.value);
});

setTimeout(() => {
controller.abort();
}, 1000);

Output:

is aborted: false
is aborted: true

One of the most important responsibilities of an application is to accurately present the current state of the system. Such an application will have to implement the means to:

  1. Fetch the current state and present it to the user.

  2. Subscribe to state changes:

    • On change, goto 1.

While step 1 is rather trivial, step 2 turns out to contain lots of complexity in practice, especially if many different sources of state (e.g. objects) are involved.

Many frameworks have found different solutions for keeping the UI synchronized with the application's state (e.g. React, Vue, Flux architecture, store libraries such as Zustand/VueX/Pinia, etc.). These solutions often come with some trade-offs:

  • They are often tied to an UI framework (e.g. React).
  • They may impose unusual programming paradigms (e.g. a centralized store instead of a graph of objects) that may be different to integrate with technologies like TypeScript.
  • They may only support reactivity for some objects. For example, Vue's reactivity system is based on wrapping objects with proxies; this is incompatible with some legitimate objects - a fact that can be both surprising and difficult to debug.
  • They may only support reactivity locally. For example, a store library may support reactivity within a single store, but referring to values from multiple stores may be difficult.

This library implements a different set of trade-offs, based on signals:

  • The implementation is not tied to any UI technology. It can be used with any UI Framework, or none, or multiple UI Frameworks at the same time.
  • All kinds of values are supported. Updating the current value in a reactive "box" will notify all interested parties (such as effects, watchers or computed objects). However, values that have not been prepared for reactivity will not be deeply reactive: when authoring a class, one has to use the reactive primitives or collections provided by this package.
  • State can be kept in objects and classes (this pairs nicely with TypeScript). The state rendered by the user interface can be gathered from an arbitrary set of objects.

See the comments inside the type declarations or the built TypeDoc documentation.

With npm installed, run

npm install @conterra/reactivity-core

Don't use the value of a computed signal during its own computation. The error will be obvious in a simple example, but it may also occur by accident when many objects or functions are involved.

Example:

import { computed } from "@conterra/reactivity-core";

const computedValue = computed(() => {
// Trivial example. This may happen through many layers of indirection in real world code.
let v = computedValue.value;
return v * 2;
});

console.log(computedValue.value); // throws "Cycle detected"

Updating the value of some signal from within an effect is fine in general. However, you should take care not to produce a cycle.

Example: this is okay (but could be replaced by a computed signal).

import { reactive, effect } from "@conterra/reactivity-core";

const v1 = reactive(0);
const v2 = reactive(1);
effect(() => {
// Updates v2 whenever v1 changes
v2.value = v1.value * 2;
});

Example: this is not okay.

import { reactive, effect } from "@conterra/reactivity-core";

const v1 = reactive(0);
effect(() => {
// same as `v1.value = v1.value + 1`
v1.value += 1; // throws!
});

This is the shortest possible example of a cycle within an effect. When the effect executed, it reads from v1 (thus requiring that the effect re-executes whenever v1 changes) and then it writes to v1, thus changing it. This effect would re-trigger itself endlessly - luckily the underlying signals library throws an exception when this case is detected.

Sometimes you really have to read something and don't want to become a reactive dependency. In that case, you can wrap the code block with untracked(). Example:

import { reactive, effect, untracked } from "@conterra/reactivity-core";

const v1 = reactive(0);
effect(() => {
// Code inside untracked() will not be come a dependency of the effect.
const value = untracked(() => v1.value);
v1.value = value + 1;
});

The example above will not throw an error anymore because the read to v1 has been wrapped with untracked().

NOTE: In very simple situations you can also use the .peek() method of a signal, which is essentially a tiny untracked block that only reads from that signal. The code above could be changed to const value = v1.peek().

Every update to a signal will usually trigger all watchers. This is not really a problem when using the default watch() or effect(), since multiple changes that follow immediately after each other are grouped into a single notification, with a minimal delay.

However, when using syncEffect or syncWatch, you will be triggered many times:

import { reactive, syncEffect } from "@conterra/reactivity-core";

const count = reactive(0);
syncEffect(() => {
console.log(count.value);
});

count.value += 1;
count.value += 1;
count.value += 1;
count.value += 1;
// Effect has executed 5 times

You can avoid this by grouping many updates into a single batch. Effects or watchers will not get notified until the batch is complete:

import { reactive, syncEffect, batch } from "@conterra/reactivity-core";

const count = reactive(0);
syncEffect(() => {
console.log(count.value);
});

batch(() => {
count.value += 1;
count.value += 1;
count.value += 1;
count.value += 1;
});
// Effect has executed only twice: one initial call and once after batch() as completed.

It is usually a good idea to surround a complex update operation with batch().

By default, the re-executions of effect and the callback executions of watch do not happen immediately when a signal is changed. Instead, the new executions are dispatched to occur in the next event loop iteration ("macro task"). This means that they are delayed very slightly (similar to setTimeout(..., 0)) in order to group multiple synchronous changes into a single execution (see Batching).

Consider the following example:

import { watch, effect, reactive } from "@conterra/reactivity-core";

const s = reactive(1);
effect(() => {
console.log("effect:", s.value);
});

watch(
() => [s.value],
([value]) => {
console.log("watch:", value);
}
);

s.value = 2;
console.log("after assignment");

This will print:

effect: 1           # the initial effect execution always happens synchronously
after assignment    # watch and effect did NOT execute yet
effect: 2           # now effect and watch will execute
watch: 2

If you need more control over your callbacks, you can use syncEffect and syncWatch instead:

import { syncWatch, syncEffect, reactive } from "@conterra/reactivity-core";

const s = reactive(1);
syncEffect(() => {
console.log("effect:", s.value);
});

syncWatch(
() => [s.value],
([value]) => {
console.log("watch:", value);
}
);

s.value = 2; // this line also executes the effect and the watch callback!
console.log("after assignment");

This will print:

effect: 1
effect: 2
watch: 2
after assignment

Sometimes you want to read the current value of a signal without being triggered when that signal changes. You can do that by opting out of the automatic dependency tracking using the untracked function, for example:

import { effect, reactive, untracked } from "@conterra/reactivity-core";

const s1 = reactive(0);
const s2 = reactive(0);
effect(() => {
const v1 = s1.value; // tracked read
const v2 = untracked(() => s2.value); // untracked read

console.log("effect", v1, v2);
});

s2.value = 1; // does not cause the effect to trigger again
s1.value = 1; // _does_ cause the effect to trigger again

untracked() works everywhere dependencies are tracked:

  • inside computed()
  • in effect callbacks
  • in the selector argument of watch()

The current implementation of collection types (Array, Map, Set) only supports fine grained reactivity for existing values. When the set of values is changed (e.g. by calling .push() on an array or .set with a new key on a Map), only a coarse "change event" will be emitted.

Consider the following example:

import { effect, reactiveArray } from "@conterra/reactivity-core";

const array = reactiveArray([1]);
effect(() => {
console.log("first array item", array.get(0));
});

array.push(2);

The snippet above will print the first array item twice, even though that item is never modified. The current implementation is a compromise between memory efficiency, code complexity and usability that results in this quirk.

To work around the issue, simply use a watch() or wrap the array access into a computed() signal. Both ways will ensure that the effect or callback is only triggered when the value actually changed:

import { computed, effect, reactiveArray, watch } from "@conterra/reactivity-core";

const array = reactiveArray([1]);

// This works because computed() caches its value and only propagates change
// when the value is actually updated.
// Essentially, the computed's callback will still re-execute but no one else will be notified.
const firstItem = computed(() => array.get(0));
effect(() => {
console.log("first array item (effect)", firstItem.value);
});

// This works because the callback is only invoked when the selector returns different values.
// Essentially, the selector is executed multiple times but watch() will not invoke the callback.
// (Behind the scenes, watch() is based on `computed` as well).
watch(
() => [array.get(0)],
([item]) => {
console.log("first array item (watch)", item);
}
);

// Triggers neither the effect nor the watch callback.
array.push(2);

It is a completely legitimate use case to manage asynchronous operations (involving promises) from reactive code, such as effect() or watch().

For example, the following code will re-trigger another "long running operation" whenever input changes:

import { reactive, effect } from "@conterra/reactivity-core";

const input = reactive("foo");
effect(() => {
const currentInput = input.value;
longRunningOperation(currentInput).catch((e) => {
console.error("Something went wrong", e);
});
});

input.value = "bar";

async function longRunningOperation(param: string) {
// ...
console.log("long running:", param);
}

You can also use signals to track the status of an asynchronous operation:

import { reactive, effect } from "@conterra/reactivity-core";

type JobState =
| { state: "pending" }
| { state: "done"; result: unknown }
| { state: "error"; error: unknown };

const jobState = reactive<JobState>({ state: "pending" });
effect(() => {
console.log(jobState.value);
});

performJob()
.then((result) => {
jobState.value = { state: "done", result };
})
.catch((error) => {
jobState.value = { state: "error", error };
});

async function performJob() {
// ...
return 42;
}

However, one should not use asynchronous code (i.e. the keywords async and await) directly in an effect/watch/computed. The following snippet is bad style and can lead to surprising behavior:

import { effect, reactive } from "@conterra/reactivity-core";

const s1 = reactive("a");
const s2 = reactive("b");

/// XXX BAD style
/// Note the `async` keyword
effect(async () => {
const v1 = s1.value;
const result = await functionThatReturnsAPromise(v1); // (1)
const v2 = s2.value;
console.log(v2);
});

setTimeout(() => {
s2.value = "c"; // (2)
}, 1000);

async function functionThatReturnsAPromise(v1: string) {
// ...
}

The effect will be executed once (initially) but it will not be triggered by the update in (2).

This is because the original read (s2.value) was not observed by the effect, which will become more obvious when we write the same effect in a different style:

// does pretty much the same as the previous effect
effect(() => {
const v1 = s1.value;
functionThatReturnsAPromise(v1).then((result) => {
const v2 = s2.value;
console.log(v2);
});
});

While the read to s1.value happens directly inside the effect, the access to v2.value happens later, possibly much later. No matter how long it takes, the callback executed by the effect will already have completed by then: all APIs in this package can only track reactive dependencies in synchronous code.

If you must use an asynchronous function directly in a reactive context, keep in mind that only the code until the first await statement will actually become reactive. However, because this is confusing and error prone, it is best to avoid it altogether.

The different variants of watch and effect support a ctx parameter, which can be used to cancel the object from within its own callback. This can be useful to wait for a certain condition, while ensuring that the callback does not trigger again after the condition is met.

For example:

import { reactive, ReadonlyReactive, watchValue } from "@conterra/reactivity-core";

// Waits for the signal to be at least 2.
function waitForTwo(signal: ReadonlyReactive<number>): Promise<void> {
return new Promise((resolve) => {
const handle = watchValue(
() => signal.value,
(value, _oldValue, ctx) => {
console.log("intermediate value", value);

// resolve the promise when the condition is met
if (value >= 2) {
// may result in error: handle.destroy();
// this always works:
ctx.destroy();
resolve();
}
},
{
// run immediately to check the initial value as well
immediate: true
}
);
});
}

const signal = reactive(0);
waitForTwo(signal).then(() => {
console.log("done");
});

setTimeout(() => {
signal.value += 1;
setTimeout(() => {
signal.value += 1;
setTimeout(() => {
// 3 is not printed by the watch callback since it has been destroyed
signal.value += 1;
}, 250);
}, 250);
}, 250);

// Prints:
// intermediate value 0
// intermediate value 1
// intermediate value 2
// done

In the example above, the watch callback resolves the promise (and destroys itself) when the signal reaches 2. The watch callback not only checks new values, but also the initial value due to immediate: true. A subtle bug could be introduced by calling handle.destroy() here, since it is not available during the initial execution of the watch callback (the callback runs inside watchValue which has not returned yet). ctx.destroy() on the other hand can always be used.

Apache-2.0 (see LICENSE file)

Primitives

Primitive building blocks for reactive code.

ExternalReactive
Reactive
ReactiveOptions
ReadonlyReactive
EqualsFunc
ReactiveGetter
batch
computed
external
getValue
isReactive
isReadonlyReactive
peekValue
reactive
synchronized
untracked

Watching

Utilities to run code when reactive values change.

CleanupHandle
EffectContext
WatchOptions
CleanupFunc
EffectCallback
SubscribeFunc
WatchCallback
WatchContext
WatchImmediateCallback
effect
syncEffect
syncWatch
syncWatchValue
watch
watchValue

Collections

Reactive collections to simplify working with complex data structures.

ReactiveArray
ReactiveMap
ReactiveSet
ReadonlyReactiveArray
ReadonlyReactiveMap
ReadonlyReactiveSet
reactiveArray
reactiveMap
reactiveSet

Struct

Utilities to create reactive data structures/objects.

ComputedMemberType
MethodMemberType
PropertyMemberType
ReactiveStructBuilder
ReactiveStructConstructor
ReactiveStructDefinition
reactiveStruct

Utilities

dispatchAsyncCallback
nextTick
reportCallbackError