DEV Community

Cover image for 3-Getting derivated values reactively - Building a Reactive System in TypeScript
Michael De Abreu
Michael De Abreu

Posted on

3-Getting derivated values reactively - Building a Reactive System in TypeScript

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],
);
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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 () => {};
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

To set the initial state, we can call the transform function with the values from getValues.

let currentValue = transform(...getValues());
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

Our tests should pass, but Typescript won't be happy about it. To see all the errors you can run:

bun tsc --noEmit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
);
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

We can start creating the array, where we will stack the callbacks.

const trackerStack: (() => void)[] = [];
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

Finally, we add the transformAndEmit function to the autoTrackCallback.

autoTrackCallback(transformAndEmit);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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.

UML diagram

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)

Collapse
 
artydev profile image
artydev

Great articles, thank you :-)