DEV Community

Cover image for Building a Reactive System in Typescript - Writing a Signal
Michael De Abreu
Michael De Abreu

Posted on

Building a Reactive System in Typescript - Writing a Signal

In the previous post, we explored functional programming and how reactive programming offers a way to manage state without direct mutation. We discussed how signals have emerged as a powerful mechanism for fine-grained reactivity, efficiently tracking dependencies and updating values automatically.

Now, it’s time to put these concepts into practice. Instead of relying on an existing library, we will build our own lightweight reactive system in TypeScript.

Defining a Signal

We know what a Signal is, but if you look at different implementations, each library has its own take on it. However, the principle is the same regardless. We need something that will hold a value, and expose a way to get and set that value and subscribe to react when the value changes.

For this exercise, we will do something like this:

const counter = state(0); // We set the initial state.

console.log(counter()) // We get the latest state at this time.

counter.subscribe((count) => {
  console.log(count) // We get the latest state at any time.
});
Enter fullscreen mode Exit fullscreen mode

You can notice the counter does not have yet a way to update it, but we will worry about that later.

Coding the Signal

I'm using Bun because it has built-in TypeScript support and a test runner that is partially compatible with Jest. However, you can use Node with your preferred test runner, whether you're working with plain JavaScript or TypeScript.

First, let's configure our project with

mkdir another-signal
cd another-signal
bun init
Enter fullscreen mode Exit fullscreen mode
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (another-signal): another-signal
entry point (index.ts): index.ts

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor autocomplete)
 + README.md

To get started, run:
  bun run index.ts
Enter fullscreen mode Exit fullscreen mode

With that, we can start coding. Create a src folder, and add a state.ts file and a state.spec.ts file. Open the state.spec.ts file, and write our first test.

import { describe, it, expect } from "bun:test";
import { state } from "./state";

describe("state", () => {
  it("should return the initial state", () => {
    const myState = state("initial");
    expect(myState()).toBe("initial");
  });
});
Enter fullscreen mode Exit fullscreen mode

This test is short, and yet it tests the initial API we defined earlier. Of course, the test will fail, because we don't have yet any export in our state file. To fix that, open the state file, and write:

export function state() {
  return () => {};
};
Enter fullscreen mode Exit fullscreen mode

With this, we can run:

bun test
Enter fullscreen mode Exit fullscreen mode

And we will have an error:

bun test v1.2.3 (8c4d3ff8)

src\state.spec.ts:
2 | import { state } from "./state";
3 |
4 | describe("state", () => {
5 |   it("should return the initial state", () => {
6 |     const myState = state("initial");
7 |     expect(myState()).toBe("initial");
                          ^
error: expect(received).toBe(expected)

Expected: "initial"
Received: undefined

      at <anonymous> (.\src\state.spec.ts:7:23)
✗ state > should return the initial state

 0 pass
 1 fail
 1 expect() calls
Ran 1 tests across 1 files. [37.00ms]
Enter fullscreen mode Exit fullscreen mode

We need the state function, to hold a value, and return a getter function to access that value. It sounds simple, and it is simple:

export function state<T>(initialValue: T) {
  let value = initialValue;

  return () => value;
}
Enter fullscreen mode Exit fullscreen mode

With that, our test now pass:

bun test
Enter fullscreen mode Exit fullscreen mode
bun test v1.2.3 (8c4d3ff8)

src\state.spec.ts:
✓ state > should return the initial state

 1 pass
 0 fail
 1 expect() calls
Ran 1 tests across 1 files. [36.00ms]
Enter fullscreen mode Exit fullscreen mode

Right now, our state function is just a simple getter, it holds a value, but it cannot be updated. Before we jump into making our state updatable, we need to define how we want updates to work.

Should we expose a simple setter function? Should updates be synchronous or asynchronous? And how can we notify subscribers when the state changes?

These are the questions we need to address as we continue designing our reactive system. In the next section, we’ll explore how to introduce updates to our state, ensuring that any dependent values react automatically.

Why Create Another Signal Library?

With so many reactive libraries out there, you might be wondering, why build another one?

Most existing signal libraries allow updating state through a setter method. While this approach is intuitive, in practice, it still feels like a mutable variable stored elsewhere. The signal tracks dependencies and triggers updates efficiently, but ultimately, it’s just another container for a value that gets replaced over time.

However, other libraries take a different approach, they update state in response to an event or action, rather than allowing direct mutations. This aligns more with the Flux architecture, where state changes are triggered by actions rather than manually setting values.

I wanted to explore this idea further and see how a signal-based system could follow a Flux-like approach, ensuring that state updates are structured, predictable, and traceable.

Beyond just building something new, this project was an opportunity to deepen my understanding of reactivity, how dependencies are tracked, how updates propagate, and how different approaches impact performance and maintainability. That’s why I decided to write these posts: to share what I learned along the way.

So, now we know how we want to update the state. But before making it updatable, let’s think about the best way to do it. Instead of directly mutating the state, we want a structured, event-driven approach, one that ensures updates are predictable and traceable. This is where events come into play.

Defining an Event, and how it can be integrated with the state

Since we want to apply the Flux pattern, we need to define how actions are created and how they update the state. We need to build something that works like this:

const increment = event();
const incrementTimes = event<number>();
const counter = state(0)
  .on(increment, (count) => count + 1)
  .on(incrementTimes, (count, times) => count + times);

console.log(counter()); // 0
increment();
console.log(counter()); // 1

counter.subscribe((count) => console.log(count));

increment() // This should fire the subscribe and log 2
incrementTimes(3) // This should fire the subscribe and log 5
Enter fullscreen mode Exit fullscreen mode

Now we have defined a way to update the state, and because we are using the Flux pattern, it's very easy to understand how and when the state is being updated.

Coding the Event

Create an event.ts and a event.spec.ts file, in the src folder. Open the event.spec.ts file, and add the test case:

import { describe, it, expect, mock } from "bun:test";
import { event } from "./event";

describe("event", () => {
  it("should notify listeners when emitted", () => {
    const myEvent = event<string>();
    const mockListener = mock();
    myEvent.subscribe(mockListener);
    myEvent("test");
    expect(mockListener).toHaveBeenCalledWith("test");
  });
});
Enter fullscreen mode Exit fullscreen mode

If we try to run the test runner, we will have a similar error to the one we had when we first ran the state test suite. So, instead, open the event.ts file, and update it to:

export function event() {
  const dispatcher = () => {};
  const subscribe = () => {};

  return Object.assign(dispatcher, { subscribe });
}
Enter fullscreen mode Exit fullscreen mode

With that, the test case will fail as expected.

Now, we need to turn this empty structure into a fully working event system. The goal is simple: whenever the event is triggered, all subscribed listeners should be notified with the provided payload. To achieve this, we need a way to keep track of listeners and notify them when the event is fired.

const listeners: ((payload => T): void)[] = [];
Enter fullscreen mode Exit fullscreen mode

Now, each time we call the created event, we want to notify the listeners that an event has been called.

const dispatcher = (payload: T) => {
  listeners.forEach((listener) => listener(payload));
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we need a way to add a listener to our internal array.

const subscribe = (listener: (payload => T): void) => {
  listeners.push(listener);
}
Enter fullscreen mode Exit fullscreen mode

After that, we should have something like this:

export function event<T = void>() {
  const listeners: ((payload: T) => void)[] = [];
  const dispatcher = (payload: T) => {
    listeners.forEach((listener) => listener(payload));
  };
  const subscribe = (listener: (payload: T) => void) => {
    listeners.push(listener);
  };

  return Object.assign(dispatcher, { subscribe });
}
Enter fullscreen mode Exit fullscreen mode

And with that, our tests should pass.

bun test
Enter fullscreen mode Exit fullscreen mode
bun test v1.2.3 (8c4d3ff8)

src\event.spec.ts:
✓ event > should notify listeners when emitted

src\state.spec.ts:
✓ state > should return the initial state

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 2 files. [142.00ms]
Enter fullscreen mode Exit fullscreen mode

Awesome! This is all for the event, so let's go back to the state.

Updating the state

So far, we’ve built a reactive state system that can hold values and allow subscriptions. However, we still haven’t given it the ability to change over time. Let’s take the next step: making our state updateable through events.

First, let's start adding more tests to our test suite:

it("should update state when an event is triggered", () => {
  const increment = event();
  const incrementTimes = event<number>();
  const counter = state(0)
    .on(increment, (count) => count + 1)
    .on(incrementTimes, (count, times) => count + times);

  increment();
  expect(counter()).toBe(1);

  incrementTimes(3);
  expect(counter()).toBe(4);
});

it("should notify listeners the state has been updated", () => {
  const updateEvent = event<string>();
  const myState = state("initial").on(updateEvent, (_, newValue) => newValue);
  const mockListener = mock();
  myState.subscribe(mockListener);
  updateEvent("updated");

  expect(mockListener).toHaveBeenCalledWith("updated");
});
Enter fullscreen mode Exit fullscreen mode

Of course, the test will fail as expected, but we can start updating the state. Since we require it to have a subscribe method, there is no reason why we can't reuse the code from the event, so we do, and add it to the state.

const listeners: ((payload: T) => void)[] = [];
const dispatcher = (payload: T) => {
  listeners.forEach((listener) => listener(payload));
};
const subscribe = (listener: (payload: T) => void) => {
  listeners.push(listener);
};
Enter fullscreen mode Exit fullscreen mode

The second thing we need is the on method. So far, we've kept things really simple here, and we are not going to start complicating it now, the on method is as simple as you would expect:

const on = <U>(
  event: { subscribe: (listener: (payload: U) => void) => void },
  reducer: (state: T, payload: U) => T,
) => {
  event.subscribe((payload) => {
    currentState = reducer(currentState, payload);
    emit(currentState);
  });
};
Enter fullscreen mode Exit fullscreen mode

This might be the most "complex" code that we had so far, but I think it's simple enough. We call on with an event and a reducer. We subscribe to that event, and it's called, we call the reducer with the currentState and the value emitted from the event, and store the result in the currentState variable, and we emit that change for whomever is listening.

Lastly, and since we need to use a similar Object.assign here, we need to move the getter to be a function as well.

const getter = () => currentState;
Enter fullscreen mode Exit fullscreen mode

With that, we can glue it all together, and return:

return Object.assign(getter, { subscribe, on });
Enter fullscreen mode Exit fullscreen mode

But, wait. To be able to chain the on method like we want, we need to return that same object from the state function, and the on method. With that, we've finished the state.

export function state<T>(initialValue: T) {
  let currentState = initialValue;

  const listeners: ((value: T) => void)[] = [];
  const emit = (payload: T) => {
    listeners.forEach((listener) => listener(payload));
  };
  const subscribe = (listener: (payload: T) => void) => {
    listeners.push(listener);
  };

  const getter = () => currentState;

  const on = <U>(
    event: { subscribe: (listener: (payload: U) => void) => void },
    reducer: (state: T, payload: U) => T,
  ) => {
    event.subscribe((payload) => {
      currentState = reducer(currentState, payload);
      emit(currentState);
    });
    return Object.assign(getter, { subscribe, on });
  };

  return Object.assign(getter, { subscribe, on });
}
Enter fullscreen mode Exit fullscreen mode

Wrapping It Up

With this implementation, we now have a fully reactive state management system that:
✔ Uses signals to hold state.
✔ Supports subscriptions for automatic reactivity.
✔ Allows event-driven updates, aligning with Flux principles.

This system might be minimal, but it provides a strong foundation to build upon. This approach was so effective that I used it to replace the mutable variables in my Lexer and Parser.

In the next post, we’ll explore optimizations, computed values, and handling asynchronous updates, making our reactive system even more powerful.

Top comments (0)