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.
});
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
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
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");
});
});
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 () => {};
};
With this, we can run:
bun test
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]
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;
}
With that, our test now pass:
bun test
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]
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
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");
});
});
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 });
}
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)[] = [];
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));
}
Lastly, we need a way to add a listener to our internal array.
const subscribe = (listener: (payload => T): void) => {
listeners.push(listener);
}
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 });
}
And with that, our tests should pass.
bun test
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]
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");
});
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);
};
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);
});
};
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;
With that, we can glue it all together, and return:
return Object.assign(getter, { subscribe, on });
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 });
}
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)