Getting derivated values reactively
In the previous post, we built a reactive state system that allows us to define a state, subscribe to changes, and update it through event-driven actions. However, as we scale our system, we'll often need to derive new values based on the existing state.
For example, imagine we have a state holding a list of prices, and we need to calculate the total. Sure, we could manually sum them every time we need the value, but that's not a very declarative approach. Instead, wouldn't it be better if we could just say: 'Hey, whenever this list of prices changes, automatically update the total'?
That's exactly where computed values come in. They allow us to:
✔ Automatically derive state from other signals.
✔ Ensure recalculations happen only when necessary.
✔ Keep our code declarative and efficient.
In this post, we'll explore how to implement computed() in our reactive system, making sure it updates efficiently whenever its dependencies change.
Let's get started!
Defining computed values and how they work
This is the most exciting part so far because it's where Reactive Programming really starts to shine. But first, we need to define how we want this function to work. When we defined the state and how to update it, we used the Flux pattern to create something similar to Redux. Considering this, we could say that computed values work similarly to selectors. However, instead of selecting from a single centralized state, each computed function derives its value from multiple state instances.
const total = computed(
(prices) => prices.reduce((total, price) => total + price),
[prices],
);
With that, we have a simple computed function that includes a dependency array, similar to what React does. However, there's a key difference: in our approach, the callback receives the state directly.
Coding the computed function
Same as before, we will start by creating two files, computed.ts
and computed.spec.ts
, and fill the spec file with the initial tests.
import { describe, it, expect } from "bun:test";
import { computed } from "./computed";
import { state } from "./state";
import { event } from "./event";
describe("computed", () => {
it("should return the computed value", () => {
const prices = state<number[]>([5, 5, 5]);
const totalPrice = computed(
(prices) => prices.reduce((total, price) => total + price, 0),
[prices],
);
expect(totalPrice()).toBe(15);
});
it("should update the computed value each time a dependency change", () => {
const addPrice = event<number>();
const prices = state<number[]>([5, 5, 5]).on(addPrice, (state, price) => [
...state,
price,
]);
const totalPrice = computed(
(prices) => prices.reduce((total, price) => total + price),
[prices],
);
expect(totalPrice()).toBe(15);
addPrice(10);
expect(totalPrice()).toBe(25);
});
});
Here, we're testing two things: first, that the computed value is calculated correctly; and second, that it updates whenever a dependency changes.
After that, we need to fill the computed.ts
file with:
export function computed<R>(
transform: (...args: any[]) => R,
dependencies: any[],
) {
return () => {};
}
With that, our tests will fail, as expected.
To start with the computed, we need a way to get the latest state from all our dependencies, and since we are marking our dependencies as an array, we can use a map to extract it.
const getValues = () => dependencies.map((dep) => dep());
To set the initial state, we can call the transform
function with the values from getValues
.
let currentValue = transform(...getValues());
Since our computed function supports subscriptions, we can reuse the logic from state and event, adjusting the type and the naming where necessary.
const listeners: ((value: R) => void)[] = [];
const emit = (payload: R) => {
listeners.forEach((listener) => listener(payload));
};
const subscribe = (listener: (payload: R) => void) => {
listeners.push(listener);
};
const getter = () => currentValue;
You may have noticed that something is missing. We still need to update the state whenever any dependency changes. What we need is a way to listen every time any of the dependencies is updated, and we have that already with subscribe
, and all of our dependencies have a subscribe method, so we can subscribe and update the internal value of the computed function, each time any of the dependencies are updated.
dependencies.forEach((dep) => {
dep.subscribe(() => {
currentValue = transform(...getValues());
emit(currentValue);
});
});
With all that, we should be able to create the computed
function. It should be something like this:
export function computed<R>(
transform: (...args: any[]) => R,
dependencies: any[],
) {
const getValues = () => dependencies.map((dep) => dep());
let currentValue = transform(...getValues());
const listeners: ((value: R) => void)[] = [];
const emit = (payload: R) => {
listeners.forEach((listener) => listener(payload));
};
const subscribe = (listener: (payload: R) => void) => {
listeners.push(listener);
};
const getter = () => currentValue;
dependencies.forEach((dep) => {
dep.subscribe(() => {
currentValue = transform(...getValues());
emit(currentValue);
});
});
return Object.assign(getter, { subscribe });
}
Our tests should pass, but Typescript won't be happy about it. To see all the errors you can run:
bun tsc --noEmit
You will get a list of errors. I'll show the first one only, but you can see a pattern.
src/computed.spec.ts:10:34 - error TS7006: Parameter 'total' implicitly has an 'any' type.
10 (prices) => prices.reduce((total, price) => total + price, 0),
~~~~~
Found 4 errors in the same file, starting at: src/computed.spec.ts:10
We have four errors, all originating from the same issue: TypeScript doesn't infer the type of total and price inside the reduce function. One way to fix this is by explicitly typing the argument: prices: number[]
. However, since we are already passing a state object into the dependency list, we should be able to infer this type automatically, shouldn't we?
Of course, we could do that. But wouldn't it be even better if we could eliminate the dependency list altogether? If we could just somehow be able to automatically listen to every update in each state being used inside the computed
function? We are already listening to updates, but how can we do it automatically?
Replacing the Dependency List with Auto-Tracking
It might sound like this is a really complex thing to do, but it's actually pretty easy, much like everything we have done so far. Let's define how the computed
function should work with an automatic tracker, and how we could integrate it into our code.
const total = computed(() =>
prices().reduce((total, price) => total + price, 0),
);
Here we don't have a dependencies array. But we have a function, that should be called each time prices
is updated, and prices
itself. What we want to do, is to register the callback passed to computed
to the prices
subscription.
But we could also have something like this:
const totalLabel = computed(() => `Total: ${total()} EUR`);
We must support nested computations while ensuring that each computed callback subscribes only to its actual dependencies, avoiding unnecessary subscriptions.
With all that considered, we can make a resume of the things we know.
- We have a callback that we need to register.
- We are calling a getter in each dependency.
- We need to stack callbacks to manage different subscription scopes.
If we maintain a stack of currently tracked computations, we can dynamically capture dependencies when state getters are called inside computed()
. This allows for an automatic dependency tracking mechanism without requiring an explicit dependency list.
Adding Auto-Tracking for Computed Values
Let's create an autotracker.ts
file. The autotracker
won't have a spec file, because its state will be tested as part of the computed spec file. So, let's update the computed.spec.ts
.
import { describe, it, expect } from "bun:test";
import { computed } from "./computed";
import { state } from "./state";
import { event } from "./event";
describe("computed", () => {
it("should return the computed value", () => {
const prices = state<number[]>([5, 5, 5]);
const totalPrice = computed(() =>
prices().reduce((total, price) => total + price, 0),
);
expect(totalPrice()).toBe(15);
});
it("should update the computed value each time a state change", () => {
const addPrice = event<number>();
const prices = state<number[]>([5, 5, 5]).on(addPrice, (state, price) => [
...state,
price,
]);
const totalPrice = computed(() =>
prices().reduce((total, price) => total + price),
);
expect(totalPrice()).toBe(15);
addPrice(10);
expect(totalPrice()).toBe(25);
});
it("should update the computed value each time a computed change", () => {
const addPrice = event<number>();
const prices = state<number[]>([5, 5, 5]).on(addPrice, (state, price) => [
...state,
price,
]);
const totalPrice = computed(() =>
prices().reduce((total, price) => total + price),
);
const totalLabel = computed(() => `Total: ${totalPrice()} EUR`);
expect(totalLabel()).toBe("Total: 15 EUR");
addPrice(10);
expect(totalLabel()).toBe("Total: 25 EUR");
});
});
We can start creating the array, where we will stack the callbacks.
const trackerStack: (() => void)[] = [];
After that, we need to add a callback to the stack, call it, and remove it from the stack. It will make sense soon.
export function autoTrackCallback(callback: () => void) {
trackerStack.push(callback);
callback();
trackerStack.pop();
}
Lastly, we need a way to subscribe to the latest callback added to the stack.
export function subscribeAutoTracker(
subscribe: (listener: () => void) => void,
) {
const tracker = trackerStack.at(-1);
tracker && subscribe(tracker);
}
With all that, we can start using the tracking in our computed
function. First, we need to remove the dependencies
array and all related code.
export function computed<R>(transform: (...args: any[]) => R) {
let currentValue: R;
const listeners: ((value: R) => void)[] = [];
const emit = (payload: R) => {
listeners.forEach((listener) => listener(payload));
};
const subscribe = (listener: (payload: R) => void) => {
listeners.push(listener);
};
const getter = () => currentValue;
return Object.assign(getter, { subscribe });
}
We need to pass a callback to autoTrackCallback
, but which one? This callback will be called each time one of the dependencies has been updated, and when it happens, we need to update the internal currentValue
and emit the new value. That should be the function of the callback we add to the autoTrackCallback
, so let's create it:
const transformAndEmit = () => {
currentValue = transform();
emit(currentValue);
};
Finally, we add the transformAndEmit
function to the autoTrackCallback
.
autoTrackCallback(transformAndEmit);
If we go back one minute to the autoTrackCallback
function, we can see that we are pushing the callback and then calling it. This call ensures that the getter functions from all dependencies are executed, making it the perfect place to subscribe to the callback. Popping the callback afterward ensures that the stack is cleared once all subscriptions are registered.
The getter
is an inline function returning the currentValue
. Let's update it, so it also subscribes to the autotracker
.
const getter = () => {
subscribeAutoTracker(subscribe);
return currentValue;
};
We also need to apply this change in the state
function. Open the state.ts
file find the getter
function, and update it to, an almost identical version:
const getter = () => {
subscribeAutoTracker(subscribe);
return currentState;
};
If we run the tests now, all tests are passing. Yaju!
Wrapping It Up
With this update, our computed values no longer require manually listing dependencies. We now have a fully reactive system that updates automatically whenever any dependent state changes.
Here is an overview of our Reactive System so far:
- Event<T>: Allows firing events and subscribing to them.
- State<T>: Represents a reactive value that updates in response to events.
- Computed<T>: Derives new values reactively, tracking dependencies automatically.
- AutoTracker: Manages subscription scopes dynamically, ensuring that computed values track only the state they depend on.
With this architecture, we have a fully declarative and efficient reactive system.
In the next posts, we'll make some optimizations, handle side effects with an effect function, and further enhance our Reactive System.
Top comments (1)
Great articles, thank you :-)