A UI framework independent reactivity library, based on signals, that supports all kinds of values.
With pnpm installed, run
pnpm add @conterra/reactivity-core
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 }
);
// prints "John Doe"
firstName.value = "Jane";
// prints "Jane Doe" (asynchronously, see "Timing and batching")
For more details, see Core concepts.
One of the most important responsibilities of an application is to accurately present the current state of the system. Such an application has to:
While step 1 is rather trivial, step 2 contains 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 trade-offs:
This library implements a different set of trade-offs, based on signals:
Signals are reactive "boxes" that contain a value that may change at any time.
They can be watched for changes, for example by registering a callback using watch().
Signals can be composed via computed signals, whose values are derived from other signals and are automatically kept up to date.
They can also be used in classes (or plain objects) to organize state by concern or privacy.
reactive<T>() creates the most basic kind of signal.
The following snippet creates a signal r that initially holds the value "foo":
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"
Read the current value through the .value property.
If the signal is writable (as signals returned by reactive<T>() are), assign to .value to update it.
Whenever the value of a signal changes, all watchers (parties interested in the current value) are notified automatically.
computed() derives a value from other signals.
It takes a callback function that may read any number of signals and returns a 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); // prints 42
age.value = 22;
console.log(doubleAge.value); // prints 44
Computed signals only re-compute their value when any of their dependencies have changed. For as long as nothing has changed, the current value is cached. This makes even complex computed signals very efficient.
The callback of a computed signal should be free of side effects: it computes a value (possibly very often), and it should not change the state of the application while doing so.
NOTE: Not every derived value needs to be a
computed. Sometimes a getter is sufficient. See MaybeReactive.
effect() runs code whenever the signals it uses change:
import { reactive, effect } from "@conterra/reactivity-core";
const r = reactive("foo");
// Effect callback is executed once immediately; prints "foo"
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"
r.value = "bar";
effect(callback) works like this:
callback immediately (synchronously).callback.
This also works indirectly, for example when calling functions which internally use signals.callback (asynchronously by default, see Async vs. sync dispatch) and tracks dependencies again.Computed signals can be used in effects 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;
watchValue() gives fine grained control over what is observed:
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 an optional options object:
effect()).
It must return the value you want to watch and should not have side effects.immediate: true, the callback also runs for the initial value.
The dispatch option controls when the callback runs, see Async vs. sync dispatch.In this example, the callback only runs when the computed sum actually changed.
watch() works the same way, but its selector returns an array of values which is compared shallowly.
This makes it easy to watch multiple values at once:
import { reactive, watch } from "@conterra/reactivity-core";
const a = reactive(1);
const b = reactive(2);
watch(
() => [a.value, b.value],
([a, b]) => {
console.log(a, b);
}
);
Both watch() and watchValue() accept a custom equal option (see Complex values and custom equality).
The callback receives the previous value as 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
With immediate: true, the previous value is undefined during the first execution (there is no previous value).
effect() is simpler, but runs whenever any signal it read changed:
import { reactive, effect } from "@conterra/reactivity-core";
const r1 = reactive(0);
const r2 = reactive(1);
const r3 = reactive(2);
// Runs whenever _any_ of the signals changed,
// even if the sum turns out to be the same.
effect(() => {
const sum = r1.value + r2.value + r3.value;
console.log(sum);
});
In complex effect callbacks it can be difficult to control which signals are ultimately used, so the effect may run more often than intended.
Use watchValue() or watch() in that case.
| Function | Kind of values | Callback condition |
|---|---|---|
effect() |
N/A | After any used signal changes. |
watchValue() |
Single value (compared with Object.is). |
After the watched value changed. |
watch() |
Multiple values (via array with shallow equality). | After one or more of the watched values changed. |
watchValue() and watch() are almost identical: only their default equal function differs.
All three run their callbacks asynchronously by default; see Async vs. sync dispatch.
effect(), watch() and watchValue() return a CleanupHandle:
import { effect, watch } from "@conterra/reactivity-core";
const h1 = effect(() => {
/* ... */
});
const h2 = watch(
() => [/* ... */],
() => {
/* ... */
}
);
// When you are no longer interested in changes:
h1.destroy();
h2.destroy();
A watcher that is not destroyed keeps executing (possibly forever). This leads to unintended side effects, unnecessary memory consumption and wasted computation.
Effect and watch callbacks may return a function. It is invoked before the callback runs again, and when the effect / watch is destroyed. Use it to undo or cancel an action started by your callback.
The following example fetches user details whenever the user 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);
}
Effect callbacks (first argument) and watch callbacks (third argument) receive a ctx object.
ctx.destroy() destroys the effect / watch from within its own callback.
This is useful to wait for a condition and stop afterwards:
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
Calling handle.destroy() here would be a subtle bug: with immediate: true, the callback first runs inside watchValue(), before handle has been assigned.
ctx.destroy() always works.
Use signals in classes (or plain objects) to implement reactive objects:
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 -> watchers that used the `name` getter are notified.
// This setter (which could also be a normal method) can enforce preconditions.
this._name.value = value;
}
}
Instances of Person are reactive, since their state is 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";
Classes can also expose computed values or accessor methods:
import { reactive, computed } from "@conterra/reactivity-core";
// 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;
}
}
Signals support any kind of value, for example objects:
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" };
By default, change detection uses Object.is.
Objects, arrays and other reference types therefore trigger changes even if their contents are equivalent (equal content, different identity).
For example, this assignment triggers the watcher from the previous example, even though the name is the same:
// new object and thus a change
currentUser.value = { name: "User 1" };
reactive() and computed() accept a custom equal function to ignore such updates:
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" };
If you only need custom equality for a single watcher, use the equal option of watchValue() / watch():
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;
}
}
);
// Callback is not invoked because the name is the same.
currentUser.value = { name: "User 1" };
A signal can hold an object, an array, or any other container (e.g. Map / Set).
However, you are only notified when the container is replaced, not when its content changes.
In other words, plain JavaScript values are not deeply reactive.
There are two recommended approaches.
Convenient for small collections or collections that don't update very often. Instead of updating a collection in place, replace it with an updated copy:
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, a normal signal is sufficient for reactivity.
authors.value = authors.value.concat(name);
}
addAuthor("King");
Alternatively, use one of the reactive collections:
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");
ReactiveArray<T> (created via reactiveArray()) behaves largely like a normal Array<T>.
Most standard methods have been reimplemented with support for reactivity.
The major difference is that the [] operator cannot be used.
Use array.get(index) and array.set(index, value) instead.
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
ReactiveSet<T> (created via reactiveSet()) is a substitute for the standard Set<T>.
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
ReactiveMap<K, V> (created via reactiveMap()) is a substitute for the standard Map<K, V>.
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"
reactiveStruct() is a shortcut for defining simple reactive classes without writing the signal boilerplate yourself:
import { computed, reactiveStruct } from "@conterra/reactivity-core";
// declare a type for the reactive object
interface Person {
firstName: string;
lastName: string;
}
// define a class
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); // prints "John Doe"
person.firstName = "Jane";
console.log(fullName.value); // prints "Jane Doe"
The define() function can be used to
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() {
// re-computed 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}`);
}
}
});
const person = new PersonClass({
firstName: "John",
lastName: "Doe"
});
person.printName(); // prints "My name is John Doe"
person.firstName = "Jane";
person.printName(); // prints "My name is Jane Doe"
Reactive structs are intended for very simple classes: objects with reactive properties, computed properties and methods. They do not support base classes or private properties. For anything more advanced, write a regular class using signals (see Reactive objects and classes); it may still use a reactive struct internally.
Constant signals never change their value.
They are helpful for APIs that expect a ReadonlyReactive<T>:
import { constant } from "@conterra/reactivity-core";
const foo = constant(3);
console.log(foo.value); // prints 3
foo.value = 4; // TypeScript error and runtime error
Reactive signals may change arbitrarily, and computed signals are completely derived from other signals. Linked signals are a hybrid of both:
A component lets the user select a value from a set of options that may change behind the scenes:
import { reactive, linked } from "@conterra/reactivity-core";
const options = reactive(["a", "b", "c"]);
const currentOption = linked(() => options.value[0]); // default to first item
// (1)
console.log(currentOption.value); // prints "a"
// (2)
currentOption.value = "b";
console.log(currentOption.value); // prints "b"
// (3)
options.value = ["x", "y", "z"];
console.log(currentOption.value); // prints "x"
currentOption is initialized to the first item of options.options.value remains unchanged, currentOption.value can be changed freely.options.value resets currentOption.value to the first item of the new options.The previous example always resets the user's choice, even if it would still be valid.
The optional second argument to linked() computes the new value from the new source and the previous value:
import { reactive, linked } from "@conterra/reactivity-core";
const options = reactive(["a", "b", "c"]);
const currentOption = linked(
() => options.value,
(options, previousValue?: string) => {
if (previousValue != null && options.includes(previousValue)) {
// Previous value is still valid
return previousValue;
}
// Reset to first value
return options[0];
}
);
console.log(currentOption.value); // prints "a"
currentOption.value = "b";
console.log(currentOption.value); // prints "b"
options.value = ["x", "y", "b"];
console.log(currentOption.value); // still prints "b"
options.value = ["x", "y", "z"];
console.log(currentOption.value); // prints "x"
linked supports a custom equal function.
It applies to the signal's value, not to the source (these can differ, see Example 2).
If you need custom equality for the source, wrap the source in a computed signal.This library cannot automatically integrate other ways of managing state (e.g. event based systems or third party reactivity systems).
Use synchronized() to integrate external state yourself.
It takes two functions:
import { synchronized } from "@conterra/reactivity-core";
const signal = synchronized(
() => /* get value from external (non-reactive) source */,
(onChange) => {
/* register onChange: to be called when the external value changes */
return () => {
/* unregister onChange again: don't leak event listeners */
};
}
);
signal.value;
The following example makes the aborted state of an AbortSignal available to reactive code.
aborted can be used like any other computed signal:
import { effect, synchronized } from "@conterra/reactivity-core";
const abortController = new AbortController();
const abortSignal = abortController.signal;
// Reactive binding for abortSignal.aborted
const aborted = synchronized(
() => abortSignal.aborted,
(onChange) => {
abortSignal.addEventListener("abort", onChange);
return () => abortSignal.removeEventListener("abort", onChange);
}
);
effect(() => {
console.log(`Aborted: ${aborted.value}`);
});
abortController.abort();
Output:
Aborted: false
Aborted: true
The type MaybeReactive<T> and the function getReactive() minimize the difference between getters, signals and plain values.
This is helpful for reusable APIs that accept arbitrary (possibly reactive) inputs:
import { MaybeReactive, getReactive, reactive, computed, effect } from "@conterra/reactivity-core";
// Example function: watch a reactive number and print its values.
function trackNumberChanges(currentNumber: MaybeReactive<number>) {
effect(() => {
const value = getReactive(currentNumber);
console.log(value);
});
}
// Not reactive, but works
trackNumberChanges(1);
// Reactive: prints new values if `reactiveNumber` changes.
const reactiveNumber = reactive(1);
trackNumberChanges(reactiveNumber);
// Two arbitrary signals
const number1 = reactive(1);
const number2 = reactive(2);
// Track a computed signal, reactive too.
const sum = computed(() => number1.value + number2.value);
trackNumberChanges(() => sum.value);
// Also works: using a getter directly, without wrapping it into a computed first.
trackNumberChanges(() => number1.value + number2.value);
A MaybeReactive<T> is one of:
| Kind of value | getReactive(v) returns |
|---|---|
| A plain value (not reactive) | v |
A signal (reactive, computed, etc.) |
v.value |
| A getter function (usually implemented using signals) | v() |
Related helpers: getValue(v) handles plain values and signals only (no getters), and toReactiveGetter(v) converts a MaybeReactive<T> into a getter function.
A getter is re-evaluated on every access, while a computed signal caches its result and only re-computes when a dependency changes.
Prefer computed for expensive derivations; use a plain getter for trivial expressions or to avoid creating a signal object.
Sometimes you want to read the current value of a signal without being triggered when that signal changes.
Opt out of dependency tracking with untracked():
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 trigger the effect again
s1.value = 1; // _does_ trigger the effect again
untracked() works everywhere dependencies are tracked:
computed()watch() / watchValue()NOTE: For a single signal,
signal.peek()is a shorthand foruntracked(() => signal.value).
effect(), watch() and watchValue() support the dispatch option:
"async" (the default):
Callbacks are invoked with a slight delay, in a later event loop iteration (macro task, similar to setTimeout(cb, 0)).
Synchronous code and handlers for already resolved promises run before the callback.
Multiple changes in quick succession may result in a single callback execution, which observes the latest value."sync":
Callbacks are invoked synchronously: either immediately within the .value assignment, or when leaving a batch() (see Batching).
This results in more callback executions and is less efficient; only use it when the exact timing is required.The initial execution of an effect (and of a watch callback with immediate: true) always happens synchronously.
Example with the default ("async"):
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");
Output:
effect: 1 # the initial effect execution always happens synchronously
after assignment # watch and effect did NOT execute yet
effect: 2 # now effect and watch execute
watch: 2
The same example with dispatch: "sync":
import { watch, effect, reactive } from "@conterra/reactivity-core";
const s = reactive(1);
effect(
() => {
console.log("effect:", s.value);
},
{ dispatch: "sync" }
);
watch(
() => [s.value],
([value]) => {
console.log("watch:", value);
},
{ dispatch: "sync" }
);
s.value = 2; // this line also executes the effect and the watch callback!
console.log("after assignment");
Output:
effect: 1
effect: 2
watch: 2
after assignment
To wait for pending async callbacks (e.g. in tests), use await nextTick().
With dispatch: "sync", every update triggers all watchers. They may be triggered
import { reactive, effect } from "@conterra/reactivity-core";
const count = reactive(0);
effect(
() => {
console.log(count.value);
},
{ dispatch: "sync" }
);
count.value += 1;
count.value += 1;
count.value += 1;
count.value += 1;
// Effect has executed 5 times, all in-between values were observed.
Group multiple updates into a single batch with batch().
Watchers are not notified until the batch is complete:
import { reactive, effect, batch } from "@conterra/reactivity-core";
const count = reactive(0);
effect(
() => {
console.log(count.value);
},
{ dispatch: "sync" }
);
batch(() => {
count.value += 1;
count.value += 1;
count.value += 1;
count.value += 1;
});
// Effect has executed only twice: once initially and once after batch() completed.
With the default "async" dispatch, synchronous updates are already grouped into a single notification.
It is still a good idea to wrap complex update operations in batch().
Don't read the value of a computed signal during its own computation. This is obvious in a simple example, but may happen by accident when many objects or functions are involved.
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"
Writing to a signal from within an effect is fine in general, but don't produce a cycle.
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;
});
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!
});
The effect reads v1 (so it must re-execute whenever v1 changes) and then writes to v1, changing it.
It would re-trigger itself endlessly; the underlying signals library throws an exception when this is detected.
If you really need to read a value without depending on it, use an untracked read:
import { reactive, effect, untracked } from "@conterra/reactivity-core";
const v1 = reactive(0);
effect(() => {
// Code inside untracked() does not become a dependency of the effect.
const value = untracked(() => v1.value); // or: v1.peek()
v1.value = value + 1;
});
Managing asynchronous operations from reactive code is legitimate.
For example, this effect starts a new 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);
}
Signals can also 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, do not use async / await directly in an effect, watch or computed callback.
The following is bad style and leads to surprising behavior:
import { effect, reactive } from "@conterra/reactivity-core";
const s1 = reactive("a");
const s2 = reactive("b");
// BAD: 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 executes once initially, but it is not triggered by the update in (2).
The read of s2.value happens after the await and is therefore not tracked.
This becomes more obvious when writing the same effect differently:
// 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);
});
});
The read of s1.value happens directly inside the effect, but the read of s2.value happens later.
By then, the effect callback has already completed: all APIs in this package only track dependencies in synchronous code.
Only the code before the first await is reactive; since this is confusing and error prone, avoid async callbacks altogether.
All functions are exported from the package root (@conterra/reactivity-core).
See the API documentation or the type declarations for full signatures.
| Export | Purpose |
|---|---|
reactive(value, opts?) |
Writable signal. Read/write via .value. See Signals. |
computed(fn, opts?) |
Read-only signal derived from other signals; cached. See Computed signals. |
linked(source, reset?) |
Writable signal that resets when its source changes. See Linked signals. |
constant(value) |
Read-only signal that never changes. See Constant signals. |
synchronized(get, sub) |
Signal backed by external (non-reactive) state. See Integrating external state. |
batch(fn) |
Group multiple writes; watchers are notified once at the end. See Batching. |
untracked(fn) |
Run fn without tracking signal reads. See Untracked reads. |
getValue(v) |
Returns v.value if v is a signal, otherwise v. |
getReactive(v) |
Like getValue, but also calls getter functions. See MaybeReactive. |
toReactiveGetter(v) |
Converts a MaybeReactive<T> into a getter function () => T. |
isReactive(v) |
Type guard: is v a writable signal? |
isReadonlyReactive(v) |
Type guard: is v a (possibly read-only) signal? |
Types: ReadonlyReactive<T>, Reactive<T>, MaybeReactive<T>, ReactiveGetter<T>, ReactiveSource<T>, ReactiveOptions<T>, EqualsFunc<T>, SubscribeFunc.
| Export | Purpose |
|---|---|
effect(fn, opts?) |
Runs fn now and again whenever any signal it read changes. See Effects. |
watchValue(selector, cb, opts?) |
Calls cb when the selector's return value changes (Object.is). See Watch. |
watch(selector, cb, opts?) |
Like watchValue, but the selector returns an array compared shallowly. |
All three return a CleanupHandle (call .destroy() to stop) and accept dispatch: "async" | "sync" (default "async"). See Async vs. sync dispatch.
Types: CleanupHandle, CleanupFunc, EffectCallback, EffectContext, EffectOptions, WatchCallback<T>, WatchImmediateCallback<T>, WatchContext, WatchOptions<T>, DispatchType.
| Export | Purpose |
|---|---|
reactiveArray(items?) |
Reactive Array-like collection (ReactiveArray<T>, ReadonlyReactiveArray<T>). |
reactiveSet(items?) |
Reactive Set (ReactiveSet<T>, ReadonlyReactiveSet<T>). |
reactiveMap(entries?) |
Reactive Map (ReactiveMap<K, V>, ReadonlyReactiveMap<K, V>). |
See Reactive collections.
| Export | Purpose |
|---|---|
reactiveStruct<T>().define({...}) |
Creates a class with reactive, read-only, computed properties and methods. See Reactive structs. |
| Export | Purpose |
|---|---|
nextTick() |
Promise that resolves after pending async effect/watch callbacks have run. Useful in tests. |
Exports prefixed with internal_ are not part of the public API and may change at any time.
This package ships agent skills that teach AI coding agents how to use this library:
reactivity-core: signals, effects, watches and reactive collections (this package).reactivity-events: event emitters from @conterra/reactivity-events.With pnpm >= 12.11, the skills are linked into your agent's skill directory automatically once approved (via pnpm approve or in pnpm-workspace.yaml).
See the pnpm documentation for details.
# pnpm-workspace.yaml
permissions:
"@conterra/reactivity-core":
skills: true
Otherwise, copy or symlink the directories from node_modules/@conterra/reactivity-core/skills/ into your agent's skill directory (e.g. .agents/skills/ or .claude/skills/).
Apache-2.0 (see LICENSE file)
A framework agnostic library for building reactive applications.