Skip to main content

ComputedValue

ComputedValue<T> automatically updates when its dependencies change. It's derived from other StatePort instances.

Interface Definition

interface ComputedValue<T> extends StatePort<T> {
/**
* Dependencies of this computed value.
* Used by framework adapters to subscribe and recompute.
*/
dependencies: ReadonlyArray<StatePort<any>>;

// StatePort interface
get(): T;
set(value: T | ((prev: T) => T)): void; // Always throws (read-only)
subscribe?(callback: (value: T) => void): () => void;
}

Creating Computed Values

Use the createComputed function with a builder pattern:

function createComputed<Ds extends readonly StatePort<any>[]>(
...dependencies: Ds
): ComputedComputeBuilder<Ds>;

Builder Pattern

const computed = createComputed(dep1, dep2, ...)
.as((value1, value2, ...) => computedValue)
.for(owner);

Steps:

  1. createComputed(...dependencies) - Declare dependencies
  2. .as(compute) - Define the computation function
  3. .for(owner) - Provide a Disposable owner for cleanup

Function Signature

createComputed<Ds extends readonly StatePort<any>[]>(
...dependencies: Ds
): {
as<T>(
compute: (...values: {
[K in keyof Ds]: ReturnType<Ds[K]["get"]>
}) => T
): {
for(owner: Disposable): ComputedValue<T>;
};
}

Type Parameters:

  • Ds - Tuple type of dependency StatePorts (inferred from arguments)

Parameters:

  • ...dependencies: Ds - One or more StatePort dependencies

Returns: Builder object with .as() method

Computation Function

The .as() method accepts a function that receives dependency values and returns the computed value:

.as<T>(
compute: (...values: {
[K in keyof Ds]: ReturnType<Ds[K]["get"]>
}) => T
)

Type Parameters:

  • T - The type of the computed value (inferred from return type)

Parameters:

  • compute - Function that receives dependency values and returns computed value
    • Receives values in the same order as dependencies
    • Type is inferred: ReturnType<Ds[K]["get"]> for each dependency

Returns: Builder object with .for() method

Owner (Disposable)

The .for() method requires a Disposable owner for cleanup:

.for(owner: Disposable): ComputedValue<T>

Parameters:

  • owner: Disposable - Object with [Symbol.dispose]() method

Returns: ComputedValue<T> instance

Common Owners:

  • DisposableResource classes (using this)
  • Objects created with makeDisposableObject
  • Custom objects implementing Disposable

Methods

get(): T

Returns the current computed value. The computation function is called if dependencies have changed.

Returns: The current computed value of type T

Memoization: The result is memoized. The computation only runs when dependencies change (checked using Object.is()).

Example:

const a = new ReactiveValue(2);
const b = new ReactiveValue(3);

const sum = createComputed(a, b)
.as((x, y) => x + y)
.for(owner);

console.log(sum.get()); // 5 (computed: 2 + 3)
a.set(5);
console.log(sum.get()); // 8 (recomputed: 5 + 3)
console.log(sum.get()); // 8 (cached, no recomputation)

set(value: T | ((prev: T) => T)): void

Always throws an error. Computed values are read-only.

sum.set(10); // Error: "Computed values are read-only"

subscribe(callback: (value: T) => void): () => void

Subscribes to computed value changes. Returns an unsubscribe function.

Parameters:

  • callback: (value: T) => void - Function called when the computed value changes

Returns: An unsubscribe function

Behavior:

  • Notifications are deduplicated using queueMicrotask
  • If multiple dependencies change in the same tick, only one notification is sent
  • The callback receives the latest computed value after all dependencies have updated

Example:

const a = new ReactiveValue(2);
const b = new ReactiveValue(3);

const sum = createComputed(a, b)
.as((x, y) => x + y)
.for(owner);

let notificationCount = 0;
sum.subscribe?.((value) => {
notificationCount++;
console.log('Sum:', value);
});

// Change both dependencies in the same tick
a.set(10);
b.set(20);
// After microtask: notificationCount is 1 (deduplicated)
// Logs: "Sum: 30" (latest value after both changes)

dependencies: ReadonlyArray<StatePort<any>>

Read-only array of dependency StatePorts. Framework adapters use this to subscribe to dependencies.

Example:

const computed = createComputed(a, b, c)
.as((x, y, z) => x + y + z)
.for(owner);

console.log(computed.dependencies); // [a, b, c]

Memoization

Computed values are memoized by default:

  • The computation function only runs when dependencies change
  • Dependencies are compared using Object.is()
  • The cached result is returned if dependencies haven't changed

Example:

let computationCount = 0;

const computed = createComputed(a, b)
.as((x, y) => {
computationCount++;
return x + y;
})
.for(owner);

computed.get(); // computationCount = 1
computed.get(); // computationCount = 1 (cached)
a.set(5); // dependency changed
computed.get(); // computationCount = 2 (recomputed)
computed.get(); // computationCount = 2 (cached)

Notification Deduplication

When multiple dependencies change in the same execution context, notifications are deduplicated:

  • Uses queueMicrotask to batch notifications
  • Only one notification is sent per subscriber, even if multiple dependencies change
  • The callback receives the latest computed value after all changes

Example:

const a = new ReactiveValue(1);
const b = new ReactiveValue(2);

const sum = createComputed(a, b)
.as((x, y) => x + y)
.for(owner);

let callCount = 0;
sum.subscribe?.(() => {
callCount++;
});

// Multiple dependency changes in the same tick
a.set(10);
b.set(20);
a.set(15);
// After microtask: callCount is 1 (deduplicated)
// Callback receives sum = 15 + 20 = 35

Examples

Simple Sum

import { ReactiveValue, createComputed, DisposableResource } from '@xndrjs/core';

class Calculator extends DisposableResource {
private a = new ReactiveValue(2);
private b = new ReactiveValue(3);

public sum = createComputed(this.a, this.b)
.as((x, y) => x + y)
.for(this);

setA(value: number) {
this.a.set(value);
}

setB(value: number) {
this.b.set(value);
}
}

const calc = new Calculator();
console.log(calc.sum.get()); // 5

calc.setA(10);
console.log(calc.sum.get()); // 13

Nested Computed Values

const a = new ReactiveValue(2);
const b = new ReactiveValue(3);

const sum = createComputed(a, b)
.as((x, y) => x + y)
.for(owner);

const doubled = createComputed(sum)
.as((s) => s * 2)
.for(owner);

console.log(doubled.get()); // 10 ((2 + 3) * 2)

a.set(5);
console.log(doubled.get()); // 16 ((5 + 3) * 2)

Conditional Logic

const count = new ReactiveValue(0);
const threshold = new ReactiveValue(100);

const status = createComputed(count, threshold)
.as((c, t) => c > t ? 'high' : 'low')
.for(owner);

console.log(status.get()); // 'low'

count.set(150);
console.log(status.get()); // 'high'

Framework Integration

Computed values work seamlessly with framework adapters:

React:

import { useReactiveValue } from '@xndrjs/adapter-react';

const sum = createComputed(a, b)
.as((x, y) => x + y)
.for(owner);

function SumDisplay() {
const value = useReactiveValue(sum);
return <div>Sum: {value}</div>;
}

Solid:

import { useReactiveValue } from '@xndrjs/adapter-solid';

function SumDisplay() {
const value = useReactiveValue(sum);
return <div>Sum: {value()}</div>;
}

Svelte:

<script>
import { reactiveValue } from '@xndrjs/adapter-svelte';
const sumStore = reactiveValue(() => sum);
</script>

<div>Sum: {$sumStore}</div>

Next Steps