A very basic state management library in under 100 lines of JavaScript

Fri May 08 2020

You probably must have used or at least heard about Redux, Mobx or Vuex. These libraries are used to manage state in apps built using frameworks or libraries like React, VueJS, etc. Redux is a framework-agnostic library, that means it can be used in any Single Page Application. Well, these libraries are great for most of the use cases, but when working with a simple application, you probably wouldn't want to add another library and increase the bundle size.

In this post, I'll walk you through, how to create a basic state management solution in under 100 lines of code.

You can see the library in action here, and here's the link to the source code.

Setup

The best way to avoid exposing your library's internal variables in the global scope is to wrap everything in an IIFE (Immediately-Invoked Function Expression). This method is called the Module Revealing pattern.

I'm going to name this library as Kel.

const Kel = (function () {
  /*
  Code will go here
  */
})();

Inside the IIFE, define the variables for storing the store data and events. This library is going to make use of the Pub/Sub pattern like most of the other libraries, so all the data will be passed around using events. After that, create and return a constructor function that will initialize the store with an initial value.

const Kel = (function () {
  let store = {};

  const events = {};

  function Kel(initialStore = {}) {
    store = initialStore;
  }

  return Kel;
})();

Next, declare two prototype functions to emit the event and subscribe for the event.

const Kel = (function () {
  // ...

  Kel.prototype.on = function (eventName, cb, dep = []) {};

  Kel.prototype.emit = function (eventName, payload) {};

  // ...
})();

The on() method

Now let's write the function body of the on() method. It will accept the event name, the callback and a dependency array. Think of the dependency array as mapStateToProps, but in this case, we only want to pass the store properties that are required by the callback, instead of passing the entire store.

The first step will be to check if the callback is a type of function; if it does not then log an error in the console.

Also, you need to check if dep is a type of array. typeof operator returns object for an array, so you need to rely on an alternative way.

Object.prototype.toString.call(dep) !== "[object Array]";

Then, check if the event already exists in the events object; if it doesn't, then initialize it as an empty array.

And finally, you can push the dep array and the callback to the event.

Kel.prototype.on = function (eventName, cb, dep = []) {
  if (typeof cb !== "function") {
    console.error("on() method expects 2nd argument as a callback function");
    return false;
  }

  if (Object.prototype.toString.call(dep) !== "[object Array]") {
    console.error("on() method expects 3nd argument as an array");
    return false;
  }

  if (!events.hasOwnProperty(eventName)) events[eventName] = [];

  events[eventName].push({ dep, cb });

  return true;
};

The emit() method

In the emit() method, you will merge the payload with the store and send the updated store to the event's callback function. Think of emit as the equivalent of the Redux's dispatch method, but here you are specifying the event type and payload directly, instead of an object.

Before doing that, you need to do a few validations. First, check if the payload is a function. This can be the case when you want to emit the data that depends on the current store. Second, check if the payload is an object, else log an error and bail. And third, check if the event exists.

Kel.prototype.emit = function (eventName, payload) {
  if (typeof payload == "function") payload = payload(store);

  if (Object.prototype.toString.call(payload) !== "[object Object]") {
    console.error("Payload should be an object");
    return false;
  }

  if (!events.hasOwnProperty(eventName)) {
    console.error(`Event "${eventName}" does not exists`);
    return false;
  }

  store = { ...store, ...payload }; // shallow merge

  // ...

  return true;
};

Making The Store Immutable

Well, you might want to consider passing an immutable store to the callback. To make the store immutable, you can use the Object.freeze() method, but it won't freeze the objects inside of the store. So for that, you need a recursive solution as shown below.

function deepFreeze(o) {
  Object.freeze(o);

  Object.keys(o).forEach(key => {
    if (
      o.hasOwnProperty(key) &&
      o[key] !== null &&
      (typeof o[key] === "object" || typeof o[key] === "function") &&
      !Object.isFrozen(o[key])
    ) {
      deepFreeze(o[key]);
    }
  });

  return o;
}

Now, you can pass an immutable store by using the deepFreeze() function.

Loop through the events and only pass the keys that were passed as dependency to the callback.

Kel.prototype.emit = function (eventName, payload) {
  if (typeof payload == "function") payload = payload(deepFreeze(store));
  // ...

  store = { ...store, ...payload };

  events[eventName].forEach(({ dep, cb }) => {
    if (dep.length == 0) cb(deepFreeze(store));
    else {
      const t = {};
      dep.forEach(k => {
        if (store.hasOwnProperty(k)) t[k] = store[k];
      });

      cb(deepFreeze(t));
    }
  });
  // ...
};

Let's make Kel a singleton

This section is completely optional, but I feel that the store should be a single source of truth for all the global data, and there shouldn't be more than one instance of Kel.

So to do that, inside the IIFE, define a function once that will only execute the constructor function once and return the previous result if it is called a second time. You need to store the result of the function execution in a tracker variable and assign null to the function once it's executed.

function once(fn, context) {
  let result;

  return function () {
    if (fn) {
      result = fn.apply(context || this, arguments);
      fn = null;
    }

    return result;
  };
}

And in the end you need to return Kel wrapped in the once() function.

const Kel = function () {
  // ...

  return once(Kel);
};

Counter Example

Check out a simple counter example below.

<div class="counter">Counter <span class="counter-value">0</span></div>
<button id="inc">+</button>
<button id="dec">-</button>
const store = Kel({ count: 0 });

const COUNT_CHANGE = "countChange";

store.on(COUNT_CHANGE, ({ count }) => {
  document.querySelectorAll("span.counter-value")[0].textContent = count;
});

document.getElementById("inc").addEventListener("click", function () {
  store.emit(COUNT_CHANGE, ({ count }) => ({ count: count + 1 }));
});

document.getElementById("dec").addEventListener("click", function () {
  store.emit(COUNT_CHANGE, ({ count }) => ({ count: count - 1 }));
});

Conclusion

This was one of the simplest ways to manage state without writing complex functions or using any additional library. You could take this basic version as a starter and implement more modern JavaScript features like Proxies and do deep merge for updating the state.

If you read this far, and liked the post, please support and share.

🤙 Get in touch with me

This site is built with Gatsby and powered by Netlify