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 stateupdate
- 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
andupdate
function alongsideview
function - register a store
- run our
view
anddiff
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.
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!