DEV Community

Michael De Abreu
Michael De Abreu

Posted on

4-Optimizing and Refactoring the Code - Building a Reactive System in TypeScript

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

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

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

Then we create a function to add a listener to the array:

function subscribe(listener: EventEmitter<T>) {
  listeners.push(listener);
}
Enter fullscreen mode Exit fullscreen mode

Finally, we define a function that notifies all listeners when an event occurs:

function emit(value: T) {
  listeners.forEach((listener) => {
    listener(value);
  });
}
Enter fullscreen mode Exit fullscreen mode

That's all we need. We return these functions, and we're good to go.

return { subscribe, emit };
Enter fullscreen mode Exit fullscreen mode

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

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

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

Update the return to replace the non-existing dispatcher with the emit from the handler.

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

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

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

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

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

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

Inside it, let's start by creating a currentValue variable and storing the initial state in it.

let currentValue = initialValue as T;
Enter fullscreen mode Exit fullscreen mode

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

We can create our updater function:

function update(updater: (value: T) => T) {
  currentValue = updater(currentValue);
  emit(currentValue);
}
Enter fullscreen mode Exit fullscreen mode

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

Finally, we use the same function we are using for the getter:

function getter() {
  subscribeAutoTracker(subscribe);
  return currentValue;
}
Enter fullscreen mode Exit fullscreen mode

We return them all:

return { getter, update, set, subscribe };
Enter fullscreen mode Exit fullscreen mode

And create a type for the return:

interface StateHandler<T> extends Subscribable<T> {
  getter: StateGetter<T>;
  update: StateUpdater<T>;
  set: StateSetter<T>;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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)