Redux-like state management: Let's code a Virtual DOM! #2

Redux-like state management: Let's code a Virtual DOM! #2

Implement a Store with a reducer, actions and dispatch function

State management is a crucial feature of any modern application.

Even though there are many approaches to managing the state, in this post, we will focus on just one solution.

We will try to implement a state management system similar to Redux (heavily inspired by elm-lang).

Where we left off

In the first article from this series, we wrote a simple virtual dom that renders some elements on the page.

If you haven't seen part 1, make sure to read it first. You can find it here.

The code we've written until this point can be found here:

Now, let's get started.

Creating a Store

To manage the state we will create a Store class.

It will handle:

  • keeping the state
  • dispatching actions
  • updating the state
  • listening for state changes

To keep things nice and clean, let's create a separate file for it: store.ts

It will contain a default export of a Store class:

export default class Store<T> {
  // ...
}

It's a generic class that takes one type argument:

  • T - the type of a model

Now let's implement the constructor for our class:

constructor(private state: T, private update: UpdateFn<T>) {}

In the constructor we declare two fields:

  • state - that will be our initial state
  • update - update function that will be fired every time an action is dispatched. In the Redux world, it's called a "reducer".

Update function has following type signature:

type UpdateFn<T> = (state: T, action: any) => T;

Next, we need a way to listen for state changes in the store - for this, we need to create a private field listeners and a public method subscribe:

private listeners: Array<(state: T) => void> = [];

subscribe(listener: (state: T) => void) {
  this.listeners.push(listener);
}

Now the only thing that's left - we need to implement a dispatch method:

dispatch(action: any) {
  this.state = this.update(this.state, action);

  this.listeners.forEach((listener) => listener(this.state));
}

The dispatch method takes an action as an argument and then runs the update function with the current state and said action.

The result of the update function will be a new state, that is assigned back to the store.

The next step is to trigger all listeners with the updated state.

Now that we have this method implemented, we need to asynchronously dispatch an INIT_STORE action (it can be called whatever you want) to trigger the listeners for the first time.

Back in the constructor:

constructor(private state: T, private update: UpdateFn<T>) {
  setTimeout(() => {
    this.dispatch(['INIT_STORE']);
  });
}

That's it - we have a fully functional store now!

Here is the full code:

// store.ts

type UpdateFn<T> = (state: T, action: any) => T;

export default class Store<T> {
  private listeners: Array<any> = [];

  constructor(private state: T, private update: UpdateFn<T>) {
    setTimeout(() => {
      this.dispatch(['INIT_STORE']);
    });
  }

  subscribe(listener: (state: T) => void) {
    this.listeners.push(listener);
  }

  dispatch(action: any) {
    this.state = this.update(this.state, action);

    this.listeners.forEach((listener) => listener(this.state));
  }
}

Now, how do I use it?

Subscribing to the Store

Let's go back to our main.ts file.

In the last post we've implemented the following code:

const render = (root: HTMLElement, view: Function) => {
  const rendered = view();

  diff(root, null, rendered);
};

To add a store to our virtual DOM we need to change a few things here:

  • pass a model and update function alongside view function
  • register a store
  • run our view and diff functions whenever the state changes

First, let's add a few type annotations:

type Element = { type: string; props: any; children: Element[] } | string;

type Application<T> = {
  model: T;
  view: (state: T, dispatch: Function) => Element;
  update: (state: T, action: any) => T;
};

Element type is a return value of a function h we declared last week.

And an Application<T> type declares our application - model, view and update functions.

Let's see how it can be implemented:

const render = (
  root: HTMLElement,
  { model, view, update }: Application<number>
) => {
  // ...
}

Inside the render function we want to register a store:

import Store from './store'; 

// ...

const render = (
  root: HTMLElement,
  { model, view, update }: Application<number>
) => {
  const store = new Store(model, update);

  // ...
}

The store is initialized with the model and update function. model will serve an initial state, and the update function will update that state every time an action is dispatched on the store.

Next, we need to subscribe to the store changes:

const render = (
  root: HTMLElement,
  { model, view, update }: Application<number>
) => {
  const store = new Store(model, update);

  store.subscribe((state) => {
    const rendered = view(state, store.dispatch.bind(store));

    setTimeout(() => {
      diff(root, null, rendered);
    });
  });
};

We call the subscribe method on the store, passing a listener function: It will be run every time the state changes.

In the listener function we run our view function - to render new virtual DOM, and then we reconcile it with the current DOM state via the diff function.

Notice that we are now passing two arguments to the view function - a current state and a dispatch function.

That way we can access them both directly inside our virtual dom.

Virtual DOM with state management

Now we are ready to connect everything together.

Let's see how can we run the render function that we modified.

render(app, {
  model: 1,
  view(state, dispatch) {
    setTimeout(() => dispatch('increment'), 1000);

    return h('div', {}, [
      h('h1', {}, ['Hello']),
      h('p', {}, ['The value is: ', String(state)]),
    ]);
  },
  update(state, action) {
    switch (action) {
      case 'increment':
        return state + 1;
      default:
        return state;
    }
  },
});

Doesn't look so complex! Let's take a look at the entire code in the main file:

import Store from './store';

const app = document.querySelector<HTMLDivElement>('#app')!;

const h = (type: string, props: any = {}, children: any[] = []) => ({
  type,
  props,
  children,
});

type Element = { type: string; props: any; children: Element[] } | string;

type Application<T> = {
  model: T;
  view: (state: T, dispatch: Function) => Element;
  update: (state: T, action: any) => T;
};

const render = (
  root: HTMLElement,
  { model, view, update }: Application<number>
) => {
  const store = new Store(model, update);

  store.subscribe((state) => {
    const rendered = view(state, store.dispatch.bind(store));

    setTimeout(() => {
      diff(root, null, rendered);
    });
  });
};

const createElement = (node: any) => {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }

  const el = document.createElement(node.type);
  node.children.map(createElement).forEach(el.appendChild.bind(el));

  return el;
};

const diff = (
  root: HTMLElement,
  oldNode: any,
  newNode: any,
  index: number = 0
) => {
  if (!oldNode) {
    root.appendChild(createElement(newNode));
  }
};

render(app, {
  model: 1,
  view(state, dispatch) {
    setTimeout(() => dispatch('increment'), 1000);

    return h('div', {}, [
      h('h1', {}, ['Hello']),
      h('p', {}, ['The value is: ', String(state)]),
    ]);
  },
  update(state, action) {
    switch (action) {
      case 'increment':
        return state + 1;
      default:
        return state;
    }
  },
});

It's starting to feel a little cluttered - we need to do some refactoring.

Let's move the h and render functions (with their type annotations) to a separate file called dom.ts.

Next, we can move the diff and createElement functions to a separate file named diff.ts

The result is a clean app implementation in main.ts:

import { h, render } from './dom';

const app = document.querySelector<HTMLDivElement>('#app')!;

render(app, {
  model: 1,
  view(state, dispatch) {
    setTimeout(() => dispatch('increment'), 1000);

    return h('div', {}, [
      h('h1', {}, ['Hello']),
      h('p', {}, ['The value is: ', String(state)]),
    ]);
  },
  update(state, action) {
    switch (action) {
      case 'increment':
        return state + 1;
      default:
        return state;
    }
  },
});

The whole code can be viewed here:

Problem

If you run this code you will see that it's not working as intended. Each second the state is updated, but instead of replacing the old DOM node, a new one is added.

Zrzut ekranu 2022-04-24 o 01.17.04.png

That's because, in the last instalment of this series, we only handled adding new items to DOM in the diff function!

In the next post from this series, we'll fix that - we will implement a full reconciliation algorithm to make sure that the DOM is always up to date with our Virtual DOM.

Until then, please let me know what you think about this project. Did you find it interesting?

Hope to hear your feedback! Stay tuned for the next posts in this series!

Did you find this article valuable?

Support Krzysztof Kałamarski by becoming a sponsor. Any amount is appreciated!