Build a state management system with vanilla JavaScript | CSS-Tricks

State management is not a new concept in software engineering, but it is used by the most popular frameworks in the JavaScript language. Traditionally, we keep the state of the DOM itself and even declare that state as a global variable. For now, though, we have plenty of state management favorites to choose from. Examples such as Redux, MobX, and Vuex make state management across components easier. This works well for reactive frameworks such as React or Vue.

However, how are these state management libraries implemented? Can we create one ourselves? At the very least, we can get a real look at the general mechanics of state management and some of the popular apis.

Before you start, you need to have a basic knowledge of JavaScript. You should know the concept of data types and understand ES6 syntax and functionality. If not, learn here. This article is not intended to replace Redux or MobX. We’re going to do a technical exploration here, just disagree.

preface

Before we begin, let’s look at what we need to achieve.

  • The Demo project
  • The project ‘

Architecture design

Using your favorite IDE, create a folder:

~/Documents/Projects/vanilla-js-state-management-boilerplate/
Copy the code

The project structure is similar to the following:

/ SRC ├─.├ ─.├ ── ├─ ├─.├.mdCopy the code

Pub/Sub

Next, go to the SRC directory and create the js directory. Next, create the lib directory and create pubsub.js.

The structure is as follows:

/js ├── pubsub.jsCopy the code

Open pubsub.js because we are going to implement a subscribe/publish module. The full name “the Publish/Subscribe.” In our application, we create functional modules that subscribe to our named events. Other modules publish corresponding events, usually applied to a related load sequence.

Pub/Sub is sometimes hard to understand, how to simulate? Imagine you work at a restaurant and your users have a launcher and a menu. If you work in a kitchen, you know when the waiter will remove the launcher (order) and let the chef know which table has removed the launcher (order). This is an order thread corresponding to the table number. In the kitchen, some cooks need to get started. They are subscribed to the order thread until it’s finished, so the cook knows what to cook. As a result, all your chefs are doing the corresponding dishes (called callbacks) for the same ordering thread (called events).

The picture above is an intuitive explanation.

The PubSub module preloads all subscriptions and executes their respective callbacks. It only takes a few lines of code to create a very elegant response flow.

Add the following code to pubsub.js:

export default class PubSub {
  constructor() {
    this.events = {}; }}Copy the code

This.events is used to hold the events we defined.

Then add the following code under constructor:

subscribe(event, callback) {

  let self = this;

  if(! self.events.hasOwnProperty(event)) { self.events[event] = []; }return self.events[event].push(callback);
}
Copy the code

Here is a subscription method. The event argument is a string type that specifies the unique event name to be used for the callback. If there are no matching events in the Events collection, we create an empty array for subsequent checks. We then push the callback method into the event collection. If there is an Event collection, push the callback directly into it. Finally returns the collection length.

Now we need to get the corresponding subscription method, and guess what? You know: publish. Add the following code:

publish(event, data = {}) {

  let self = this;

  if(! self.events.hasOwnProperty(event)) {return [];
  }

  return self.events[event].map(callback= > callback(data));
} 
Copy the code

This method first checks to see if the event being passed exists. If not, return an empty array. If present, the methods in the collection are iterated over and data is passed in for execution. If there is no callback method, that’s ok, because the empty array we created will also apply to the SUBSCRIBE method.

This is PubSub. Let’s see what happens next!

The core Store object

Now that we have the subscribe/publish model, we want to create a dependency for the application: Store. Let’s look at it bit by bit.

Let’s take a look at what this storage object is for.

Store is our core object. Import store from ‘.. /lib/store.js’, in which you will store the status bits you wrote. So this set of states, which contains all the states of our application, has a commit method which we call mutations, and finally a dispatch method which we call Actions. In the details of this core implementation, there should be a proxy-based system that listens for and broadcasts state changes in the PubSub model.

Let’s create a new folder store under js. Then create a store.js file. Your js directory should look like this:

/ js └ ─ ─ lib └ ─ ─ pubsub. Js └ ─ ─ store └ ─ ─ store. JsCopy the code

Open store.js and introduce the subscribe/publish module. As follows:

import PubSub from '.. /lib/pubsub.js';
Copy the code

This is common in ES6 syntax and is very recognisable.

Next, start creating objects:

export default class Store {
  constructor(params) {
    let self = this; }}Copy the code

There’s a self-statement here. We need to create the default state, actions, and mutations. We also add a status element to determine the Store’s behavior at any given moment:

self.actions = {};
self.mutations = {};
self.state = {};
self.status = 'resting';
Copy the code

After that, we need to instantiate PubSub and bind our Store as a events element:

self.events = new PubSub();
Copy the code

Next we need to find out if the passed Params object includes actions or mutations. When the Store initializes, we pass the data in. Contains a collection of Actions and mutations, which controls the stored data:

if(params.hasOwnProperty('actions')) {
  self.actions = params.actions;
}

if(params.hasOwnProperty('mutations')) {
  self.mutations = params.mutations;
}
Copy the code

These are our default Settings and possible parameter Settings. Next, let’s look at how the Store object tracks changes. We’re going to implement it with a Proxy. Proxy uses half of the functionality in our state object. If we use GET, we listen every time we access data. With the same selection set, our monitoring will be applied when the data changes. The code is as follows:

self.state = new Proxy((params.state || {}), {
  set: function(state, key, value) {

    state[key] = value;

    console.log(`stateChange: ${key}: ${value}`);

    self.events.publish('stateChange', self.state);

    if(self.status ! = ='mutation') {
      console.warn(`You should use a mutation to set ${key}`);
    }

    self.status = 'resting';

    return true; }});Copy the code

What happens in this set function? This means that if there are data changes such as state.name = ‘Foo’, this code will run. Change the data and print it in our context in time. We can publish a stateChange event to the PubSub module. The callback function for any subscribed event is executed, and we check the Store’s status. The current status should be mutation, which means the status has been updated. We can add a warning to warn developers about the risks of updating data in a non-mutation state.

Dispatch and commit

Now that we’ve added the core elements to the Store, let’s add two methods. Dispatch is used to perform actions, commit is used to perform mutations. The code is as follows:

dispatch (actionKey, payload) {

  let self = this;

  if(typeofself.actions[actionKey] ! = ='function') {
    console.error(`Action "${actionKey} doesn't exist.`);
    return false;
  }

  console.groupCollapsed(`ACTION: ${actionKey}`);

  self.status = 'action';

  self.actions[actionKey](self, payload);

  console.groupEnd();

  return true;
}
Copy the code

The process is as follows: Find the action, set status if it exists, and run the action. The commit method is similar.

commit(mutationKey, payload) {
  let self = this;

  if(typeofself.mutations[mutationKey] ! = ='function') {
    console.log(`Mutation "${mutationKey}" doesn't exist`);
    return false;
  }

  self.status = 'mutation';

  let newState = self.mutations[mutationKey](self.state, payload);

  self.state = Object.assign(self.state, newState);

  return true;
}
Copy the code

Create a base component

We create a list to practice the state management system:

~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/lib/component.js
Copy the code
import Store from '.. /store/store.js';

export default class Component {
  constructor(props = {}) {
    let self = this;

    this.render = this.render || function() {};

    if(props.store instanceof Store) {
      props.store.events.subscribe('stateChange', () => self.render());
    }

    if(props.hasOwnProperty('element')) {
      this.element = props.element; }}}Copy the code

Let’s look at this string of code. First, introduce the Store class. We don’t want an instance, but more checks are placed in constructor. From constructor, we get a Render method, which might be used if the Component class is a parent of another class. If there is no corresponding method, an empty method is created.

After that, we check the Store class for a match. Verify that the Store method is an instance of the Store class; if not, do not execute. We subscribe to a global variable called the stateChange event for our program to respond to. Each state change triggers the Render method.

Based on this base component, create other components.

Create our component

Create a list:

~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/component/list.js
Copy the code
import Component from '.. /lib/component.js';
import store from '.. /store/index.js';

export default class List extends Component {

  constructor() {
    super({
      store,
      element: document.querySelector('.js-items')}); } render() {let self = this;

    if(store.state.items.length === 0) {
      self.element.innerHTML = `

You've done nothing yet 😢

`
; return; } self.element.innerHTML = ` <ul class="app__items"> ${store.state.items.map(item => { return ` <li>${item}<button aria-label="Delete this item">×</button></li> ' }).join('')} </ul> `; self.element.querySelectorAll('button').forEach((button, index) = > { button.addEventListener('click', () => { store.dispatch('clearItem', { index }); }); }); }};Copy the code

Create a count component:

import Component from '.. /lib/component.js';
import store from '.. /store/index.js';

export default class Count extends Component {
  constructor() {
    super({
      store,
      element: document.querySelector('.js-count')}); } render() {letsuffix = store.state.items.length ! = =1 ? 's' : ' ';
    let emoji = store.state.items.length > 0 ? '🙌 ' : '😢 ';

    this.element.innerHTML = `
      <small>You've done</small>
      ${store.state.items.length}
      <small>thing${suffix} today ${emoji}</small>
    `; }}Copy the code

Create a status component:

import Component from '.. /lib/component.js';
import store from '.. /store/index.js';

export default class Status extends Component {
  constructor() {
    super({
      store,
      element: document.querySelector('.js-status')}); } render() {let self = this;
    letsuffix = store.state.items.length ! = =1 ? 's' : ' ';

    self.element.innerHTML = `${store.state.items.length} item${suffix}`; }}Copy the code

The file directory structure is as follows:

/ SRC ├ ─ ─ js │ ├ ─ ─ components │ │ ├ ─ ─ count. Js │ │ ├ ─ ─ a list. The js │ │ └ ─ ─ status. The js │ ├ ─ ─ lib │ │ ├ ─ ─ component. The js │ │ └ ─ ─ pubsub. Js └ ─ ─ ─ ─ ─ store │ └ ─ ─ store. Js └ ─ ─ ─ ─ ─ the main. JsCopy the code

Improve status management

We’ve got the front end component and the main Store. Now we need an initial state, some actions and mutations. In the store directory, create a new state.js file:

~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/store/state.js
Copy the code
export default {
  items: [
    'I made this'.'Another thing'
  ]1
};
Copy the code

Continue creating actions.js:

export default {
  addItem(context, payload) {
    context.commit('addItem', payload);
  },
  clearItem(context, payload) {
    context.commit('clearItem', payload); }};Copy the code

Continue to create mutation.js

export default {
  addItem(state, payload) {
    state.items.push(payload);

    return state;
  },
  clearItem(state, payload) {
    state.items.splice(payload.index, 1);

    returnstate; }};Copy the code

Finally create index.js:

import actions from './actions.js';
import mutations from './mutations.js';
import state from './state.js';
import Store from './store.js';

export default new Store({
  actions,
  mutations,
  state
});
Copy the code

Final integration

Finally we integrated all the code into main.js, as well as index.html:

~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/main.js
Copy the code
import store from './store/index.js'; 

import Count from './components/count.js';
import List from './components/list.js';
import Status from './components/status.js';

const formElement = document.querySelector('.js-form');
const inputElement = document.querySelector('#new-item-field');
Copy the code

With everything in place, add the interaction:

formElement.addEventListener('submit', evt => {
  evt.preventDefault();

  let value = inputElement.value.trim();

  if(value.length) {
    store.dispatch('addItem', value);
    inputElement.value = ' '; inputElement.focus(); }});Copy the code

Add render:

const countInstance = new Count();
const listInstance = new List();
const statusInstance = new Status();

countInstance.render();
listInstance.render();
statusInstance.render();
Copy the code

This completes a state management system.