So far, we've built a solid foundation for our reactive system. We started by defining a way to manage the state and respond to events. Then, we added computed values, enhancing them with automatic dependency tracking and removing the need to manually list dependencies. This has allowed us to write more declarative and efficient code.
However, as our implementation has grown, we've begun to notice some repeating patterns across different parts of the system. Functions like state
, computed
, and event
all share similar logic for handling subscriptions and emitting events. This duplication makes the code harder to maintain and increases the risk of inconsistencies.
In this post, we'll focus on refactoring our implementation to consolidate shared logic. Our goal is to extract reusable utilities that improve modularity, readability, and extensibility. This refactoring will also introduce TypeScript types in a more structured way, improving the development experience without adding unnecessary complexity.
By the end of this post, our reactive system will be leaner, more efficient, and easier to maintain. Let's dive in!
Improving the development experience
A good development experience is crucial, and TypeScript helps us achieve that. Until now, we've been using TypeScript without paying much attention to types. In this section, we'll refine our type definitions to improve the development experience. If you're unfamiliar with TypeScript, this might seem intimidating, but don't worry, we'll go through it step by step. Since we're also refactoring parts of the code, these types will naturally integrate into it.
Let's create a types.ts
file and define the types we've already been using. These type definitions won't change the logic of our implementation, but they will make our system more explicit and easier to work with.
export interface State<T> extends StateGetter<T>, Subscribable<T> {
on<U>(subscribable: Subscribable<U>, reducer: StateReducer<T, U>): State<T>;
}
export interface ComputedState<T> extends StateGetter<T>, Subscribable<T> {}
export interface Event<T = void> extends EventEmitter<T>, Subscribable<T> {}
export interface Subscribable<T> {
subscribe(listener: EventEmitter<T>): void;
}
export interface EventEmitter<T> {
(payload: T): void;
}
export interface StateGetter<T> {
(): T;
}
export interface StateReducer<T, U> {
(state: T, payload: U): T;
}
We won't rush to update the code just yet; instead, we'll take this opportunity to refactor and improve our implementation's structure.
Refactoring the listeners
If you review the code from event
, state
, and computed
, you'll notice they share duplicated logic we can abstract and reuse. The most obvious duplication is in how we handle subscriptions, and that's a great starting point. We can see that all functions store listeners in an array, provide a way to dispatch or emit new values to them, and a way to add new listeners. With that in mind, we can start by creating a handlers.ts
file. Since this is a refactoring, we don't need to add more tests, but we do need to ensure that all of our current test suite passes.
Before we start, let's create a function inside the handler.ts
file:
export function createListenersHandler<T>() {
}
Inside this function, we'll create an array to store the listeners. This is also a good place to apply the TypeScript types we just defined:
const listeners: EventEmitter<T>[] = [];
Then we create a function to add a listener to the array:
function subscribe(listener: EventEmitter<T>) {
listeners.push(listener);
}
Finally, we define a function that notifies all listeners when an event occurs:
function emit(value: T) {
listeners.forEach((listener) => {
listener(value);
});
}
That's all we need. We return these functions, and we're good to go.
return { subscribe, emit };
Since we now have well-defined types, let's create an interface for the return value:
interface ListenersHandler<T> extends Subscribable<T> {
emit: EventEmitter<T>;
}
With all that, our createListenersHandler
function should look like this:
interface ListenersHandler<T> extends Subscribable<T> {
emit: EventEmitter<T>;
}
export function createListenersHandler<T>(): ListenersHandler<T> {
const listeners: EventEmitter<T>[] = [];
function subscribe(listener: EventEmitter<T>) {
listeners.push(listener);
}
function emit(value: T) {
listeners.forEach((listener) => {
listener(value);
});
}
return { subscribe, emit };
}
Now we can use this in our functions to reduce duplicated code. Let's start with the event
. Remove the listeners
array and its related logic, then call createListenersHandler
.
const { emit, subscribe } = createListenersHandler<T>();
Update the return to replace the non-existing dispatcher
with the emit
from the handler.
return Object.assign(emit, { subscribe });
Now that we have a properEvent<T>
type, we can use it as the return type. With all that, we have the new version of the event
function.
export function event<T = void>(): Event<T> {
const { emit, subscribe } = createListenersHandler<T>();
return Object.assign(emit, { subscribe });
}
Let's update the state
function. Again, we first remove the listeners
array and its related code, then call createListenersHandler
.
const { emit, subscribe } = createListenersHandler<T>();
Since state
already had emit
and subscribe
, replacing the old listener logic with createListenersHandler
requires no further changes. Finally, we use State<T>
as the return type, and type the parameters of the on
function.
export function state<T>(initialValue: T): State<T> {
let currentState = initialValue;
const { emit, subscribe } = createListenersHandler<T>();
const getter = () => {
subscribeAutoTracker(subscribe);
return currentState;
};
const on = <U>(
source: Subscribable<U>,
reducer: StateReducer<T, U>,
) => {
source.subscribe((payload) => {
currentState = reducer(currentState, payload);
emit(currentState);
});
return Object.assign(getter, { subscribe, on });
};
return Object.assign(getter, { subscribe, on });
}
And now we do the same with computed
: we remove the listeners
, replace the code related to it with the handlers from createListenersHandler
, and use ComputedState<T>
as the return type. Similar to state
, we were already using the same names here, so no further updates are required.
export function computed<R>(transform: () => R): ComputedState<R> {
let currentValue: R;
const { emit, subscribe } = createListenersHandler<R>();
const getter = () => {
subscribeAutoTracker(subscribe);
return currentValue;
};
const transformAndEmit = () => {
currentValue = transform();
emit(currentValue);
};
autoTrackCallback(transformAndEmit);
return Object.assign(getter, { subscribe });
}
If we run our test cases, they should all pass. After all this, we've significantly simplified the event
function, and reduced state
, computed
, and the overall code.
Refactoring the state
We can still see some duplication between state
and computed
. Both store an internal value, provide a getter function that automatically subscribes, and emit updates when their values change. We can create a function to handle all that, and again, reduce the amount of duplicated code.
Add a createStateHandler
function to our handlers.ts
file.
export function createStateHandler<T>(initialValue?: T) {
}
Inside it, let's start by creating a currentValue
variable and storing the initial state in it.
let currentValue = initialValue as T;
The cast tells TS that initialValue
will always be of type T
. I'll explain this later. Now, we need a way to update our state. In the computed
function, the state is being replaced with the latest result of the transform
callback each time. However, in the state
function, we are handling a reducer, and because of this, we need to get the latest value of the internal state to pass it to the reducer. In both cases we need to emit the first value, so we are going to first create our listeners handlers.
const { emit, subscribe } = createListenersHandler<T>();
We can create our updater function:
function update(updater: (value: T) => T) {
currentValue = updater(currentValue);
emit(currentValue);
}
This function accepts a callback that receives the current value and returns the updated value. We could use that in both the state
and computed
, but since computed
doesn't actually need the current value, we wrap it to avoid the callback.
We also take this approach to simplify the logic. If the stored state were a function, there would be no reliable way, either in TypeScript or at runtime, to distinguish whether the argument passed to update
is a new value or an updater function.
function set(value: T) {
update(() => value);
}
Finally, we use the same function we are using for the getter
:
function getter() {
subscribeAutoTracker(subscribe);
return currentValue;
}
We return them all:
return { getter, update, set, subscribe };
And create a type for the return:
interface StateHandler<T> extends Subscribable<T> {
getter: StateGetter<T>;
update: StateUpdater<T>;
set: StateSetter<T>;
}
With all this, our final implementation looks like this:
interface StateHandler<T> extends Subscribable<T> {
getter: StateGetter<T>;
update: StateUpdater<T>;
set: StateSetter<T>;
}
export function createStateHandler<T>(initialValue?: T): StateHandler<T> {
let currentValue = initialValue as T;
const { emit, subscribe } = createListenersHandler<T>();
function set(value: T) {
update(() => value);
}
function update(updater: (value: T) => T) {
currentValue = updater(currentValue);
emit(currentValue);
}
function getter() {
subscribeAutoTracker(subscribe);
return currentValue;
}
return { getter, update, set, subscribe };
}
We can use this state handler to update the computed
function. Remove all the code and leave only the autoTrackCallback
call and the return statement. After that, create the state handlers.
const { getter, subscribe, set } = createStateHandler<R>();
You will notice that we are calling createStateHandler
without an argument, which means the internal state will initially be undefined. This is fine, because we are going to initialize the state before we expose a way to get it. But this is why we casted initialValue
in the code.
Next, we update the call to autoTrackCallback
:
autoTrackCallback(() => set(transform()));
That's it! Our computed
function should look like this:
export function computed<R>(transform: StateGetter<R>): ComputedState<R> {
const { getter, subscribe, set } = createStateHandler<R>();
autoTrackCallback(() => set(transform()));
return Object.assign(getter, { subscribe });
}
We can update the state
function. Similar to what we did with computed
, we remove most of the code except for the on
function, and the return statement. Then, we create the state handlers:
const { getter, subscribe, update } = createStateHandler(initialValue);
Finally, we update the subscription callback to use the update
function.
const on = <U>(source: Subscribable<U>, reducer: StateReducer<T, U>) => {
source.subscribe((payload) => {
update((value) => reducer(value, payload));
});
return Object.assign(getter, { subscribe, on });
};
With that, our state
function should look like this:
export function state<T>(initialValue: T): State<T> {
const { getter, subscribe, update } = createStateHandler(initialValue);
const on = <U>(source: Subscribable<U>, reducer: StateReducer<T, U>) => {
source.subscribe((payload) => {
update((value) => reducer(value, payload));
});
return Object.assign(getter, { subscribe, on });
};
return Object.assign(getter, { subscribe, on });
}
All functions now share as much code as possible, differing only in their specific behavior.
In this post, we focused on sharing logic across our functions. But I don't want you to feel like we didn't made any progress. Now that we have a stable way to store and compute values, it would be great to have a way to listen to those changes and react to them, like an effect
function.
Creating the effect
The effect
function should take a callback that gets executed whenever any of the states used inside it changes. It works similarly to computed
, but without storing a derived value.
As usual, we start by creating the effect.ts
and effect.spec.ts
files and adding our test suite:
describe("effect", () => {
it("should run the callback when state changes", () => {
const updateState = event<string>();
const myState = state("initial").on(updateState, (_, newState) => newState);
const spy = mock();
effect(() => {
spy(myState());
});
expect(spy).toHaveBeenCalledWith("initial");
updateState("updated");
expect(spy).toHaveBeenCalledWith("updated");
});
});
Let's open the effect.ts
file and define the effect
function. Since the implementation is so simple, rather than explaining each step manually, we can go straight to the final version:
export function effect(callback: () => void) {
autoTrackCallback(callback);
}
The effect
function uses autoTrackCallback
to automatically register and re-run the callback whenever its dependencies change.
Optimizing and Extending the System
There is still room for optimization, especially in memory management, if that's something you want to explore. By centralizing listener management in createListenersHandler
, any optimizations we implement will automatically apply across the system. Maybe you want to add a manual unsubscription system or explore how an automatic unsubscription system could work? Any improvements you want to make, you just need to update createListenersHandler
, and they will be applied system-wide.
What's next?
With this, we now have a robust foundation for a reactive system. From my perspective, this library is feature completed. In the next post, we will explore how to handle asynchronous state in a reactive system and create operators that allow finer control over state updates.
Top comments (0)