One-way state management in vanilla JavaScript

antoniovdlc

Antonio Villagra De La Cruz

Posted on August 17, 2021

One-way state management in vanilla JavaScript

Ever wonder what were the basic building blocks of one-way state management libraries such as redux or vuex? Well, you are in the right place as we will be looking at re-implementing one-way state management in vanilla JavaScript.


For the purpose of this article, we will be building a basic counter, with a button to increment the counter, a button to decrement the counter, and a button to reset the counter.

The basic markup we will be working with is the following:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Counter</title>
</head>
<body>
  <p id="counter"></p>
  <button id="increment">+</button>
  <button id="reset">Reset</button>
  <button id="decrement">-</button>

  <script src="main.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The goal is to look at different implementations of managing the state of the counter.


Let's first start with a naive implementation:

main.js

window.addEventListener("DOMContentLoaded", ignite);

function ignite() {
  const $counter = document.querySelector("#counter");
  const $increment = document.querySelector("#increment");
  const $decrement = document.querySelector("#decrement");
  const $reset = document.querySelector("#reset");

  const state = {
    counter: 0,
  };

  $increment.addEventListener("click", () => {
    state.counter = state.counter + 1
    $counter.innerText = state.counter;
  });
  $decrement.addEventListener("click", () => {
    state.counter = state.counter - 1
    $counter.innerText = state.counter;
  });
  $reset.addEventListener("click", () => {
    state.counter = 0
    $counter.innerText = state.counter;
  });
}
Enter fullscreen mode Exit fullscreen mode

We are attaching event listeners on each button, and mutating the counter field of a state object that is in scope of all the event handlers. This works fine, but we are already seeing a few places where this code might not scale so well.


The most obvious one is that we need to set the counter's inner text in each handler:

$counter.innerText = state.counter;
Enter fullscreen mode Exit fullscreen mode

It would be great if we could abstract that away in a function, such as:

function updateUI() {
  $counter.innerText = state.counter;
}
Enter fullscreen mode Exit fullscreen mode

Now our overall code looks like follows:

window.addEventListener("DOMContentLoaded", ignite);

function ignite() {
  const $counter = document.querySelector("#counter");
  const $increment = document.querySelector("#increment");
  const $decrement = document.querySelector("#decrement");
  const $reset = document.querySelector("#reset");

  function updateUI() {
    $counter.innerText = state.counter;
  }

  const state = {
    counter: 0,
  };

  $increment.addEventListener("click", () => {
    state.counter = state.counter + 1;
    updateUI();
  });
  $decrement.addEventListener("click", () => {
    state.counter = state.counter - 1;
    updateUI();
  });
  $reset.addEventListener("click", () => {
    state.counter = 0;
    updateUI();
  });
}
Enter fullscreen mode Exit fullscreen mode

This is an improvement as we only need to update the updateUI() function if we scale the counter and need to make more changes to the UI when the counter's value updates, but this is not yet as DRY as I could be ...


Enter, Proxies!

To automatically make a call to updateUI() whenever any field in the state gets updated, we will wrap the state object in a Proxy:

  const state = new Proxy(
    {
      counter: 0,
    },
    {
      set(obj, prop, value) {
        obj[prop] = value;
        updateUI();
      },
    }
  );
Enter fullscreen mode Exit fullscreen mode

Note that we could have also used an implementation of the Observer pattern, but this is less code!

Now, every time that a field in the state gets update, we will call updateUI(). This leaves us with the following code:

window.addEventListener("DOMContentLoaded", ignite);

function ignite() {
  const $counter = document.querySelector("#counter");
  const $increment = document.querySelector("#increment");
  const $decrement = document.querySelector("#decrement");
  const $reset = document.querySelector("#reset");

  function updateUI() {
    $counter.innerText = state.counter;
  }

  const state = new Proxy(
    {
      counter: 0,
    },
    {
      set(obj, prop, value) {
        obj[prop] = value;
        updateUI();
      },
    }
  );

  $increment.addEventListener("click", () => {
    state.counter = state.counter + 1;
  });
  $decrement.addEventListener("click", () => {
    state.counter = state.counter - 1;
  });
  $reset.addEventListener("click", () => {
    state.counter = 0;
  });
}
Enter fullscreen mode Exit fullscreen mode

Alright, that looks pretty neat ... but having direct mutations to the state still doesn't look that scalable and easy to reason about once we start adding more complex asynchronous interactions.


This is where one-way state management libraries really shine. Sure, it's a lot of boilerplate, and it might not make sense for simple (even asynchronous) applications, but it also brings predictability while managing state.

Ok, so let's go step by step. In most one-way state management libraries, there is a central store which has a private state and exposes a dispatch() and a getState() function. To mutate the state, we dispatch() actions, which call the main reducer() to produce the next state depending on the actual value and the action being dispatched. The state cannot be mutated outside of the store.

To achieve such a design, we have to create a closure around a state object, by first building a function that will create a store:

  function createStore(initialState, reducer) {
    const state = new Proxy(
      { value: initialState },
      {
        set(obj, prop, value) {
          obj[prop] = value;
          updateUI();
        },
      }
    );

    function getState() {
      // Note: this only works if `initialState` is an Object
      return { ...state.value };
    }

    function dispatch(action) {
      const prevState = getState();
      state.value = reducer(prevState, action);
    }

    return {
      getState,
      dispatch,
    };
  }
Enter fullscreen mode Exit fullscreen mode

Here, we have moved our previous proxied version of state inside the createStore() function, which accepts 2 arguments: the initial value of state and the main reducer used to calculate the next state depending on the dispatched action.

It returns an object with a getState() function, which returns an "unproxied" value for state. Among other things, this ensures that state is never mutated outside of the reducer() as the value returned is not the actual state held by the store.

The dispatch() function, takes in an action and calls the main reducer() with the previous value of state and said action, then assigns the newly returned state.

In our case, we can define the initalState and the reducer() as follows:

  const initialState = { counter: 0 };

  function reducer(state, action) {
    switch (action) {
      case "INCREMENT":
        state.counter = state.counter + 1;
        break;
      case "DECREMENT":
        state.counter = state.counter - 1;
        break;
      case "RESET":
      default:
        state.counter = 0;
        break;
    }

    return state;
  }
Enter fullscreen mode Exit fullscreen mode

Note that in our case, reducers are pure functions, so they need to return the new value of the state.

Finally, we initialize the store, and make the necessary changes to our event handlers and updateUI() function:

  const store = createStore(initialState, reducer);

  function updateUI() {
    $counter.innerText = store.getState().counter;
  }

  $increment.addEventListener("click", () => {
    store.dispatch("INCREMENT");
  });
  $decrement.addEventListener("click", () => {
    store.dispatch("DECREMENT");
  });
  $reset.addEventListener("click", () => {
    store.dispatch("RESET");
  });
Enter fullscreen mode Exit fullscreen mode

All together, our homemade one-way state management in vanilla JavaScript to handle a counter looks like this:

main.js

window.addEventListener("DOMContentLoaded", ignite);

function ignite() {
  const $counter = document.querySelector("#counter");
  const $increment = document.querySelector("#increment");
  const $decrement = document.querySelector("#decrement");
  const $reset = document.querySelector("#reset");

  function createStore(initialState, reducer) {
    const state = new Proxy(
      { value: initialState },
      {
        set(obj, prop, value) {
          obj[prop] = value;
          updateUI();
        },
      }
    );

    function getState() {
      // This only works if `initialState` is an Object
      return { ...state.value };
    }

    function dispatch(action) {
      const prevState = getState();
      state.value = reducer(prevState, action);
    }

    return {
      getState,
      dispatch,
    };
  }

  const initialState = { counter: 0 };

  function reducer(state, action) {
    switch (action) {
      case "INCREMENT":
        state.counter = state.counter + 1;
        break;
      case "DECREMENT":
        state.counter = state.counter - 1;
        break;
      case "RESET":
      default:
        state.counter = 0;
        break;
    }

    return state;
  }

  const store = createStore(initialState, reducer);

  function updateUI() {
    $counter.innerText = store.getState().counter;
  }

  $increment.addEventListener("click", () => {
    store.dispatch("INCREMENT");
  });
  $decrement.addEventListener("click", () => {
    store.dispatch("DECREMENT");
  });
  $reset.addEventListener("click", () => {
    store.dispatch("RESET");
  });
}
Enter fullscreen mode Exit fullscreen mode

Of course, libraries like redux or vuex take care of a lot of edge cases that we have overlooked, and add a lot more to the mix than just the concepts we have touched upon in the article, but hopefully that gives you a good idea of the logic behind some popular one-way state management libraries.

💖 💪 🙅 🚩
antoniovdlc
Antonio Villagra De La Cruz

Posted on August 17, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related