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>

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;
  });
}

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;

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

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

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();
  });
}

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();
      },
    }
  );

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;
  });
}

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,
    };
  }

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;
  }

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");
  });

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");
  });
}

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.

Antonio Villagra De La Cruz

Antonio Villagra De La Cruz

Multicultural software engineer passionate about building products that empower people.