close
close

first Drop

Com TW NOw News 2024

Writing a State Management Library in 50 Lines of JavaScript
news

Writing a State Management Library in 50 Lines of JavaScript

State management is one of the most important parts of a web application. From the use of global variables to React hooks to the use of third-party libraries such as MobX, Redux or XState to name just 3, it is one of the topics that sparks the most discussions, because it is important to master it in order to design a reliable and efficient application.

Today I propose to build a mini state management library in less than 50 lines of JavaScript based on the concept of observables. It can certainly be used as it is for small projects, but beyond this educational exercise I still recommend you to turn to more standardized solutions for your real projects.

API Definition

When starting a new library project, it is important to define from the beginning what the API could look like to capture the concept and guide development, before even thinking about technical implementation details. For a real project, it is even possible to start writing tests at this point to validate the implementation of the library as written using a TDD approach.

Here we want to export a single class which we will call State which will be instantiated with an object containing the initial state and a single observe method that allows us to subscribe to state changes using observers. These observers should only be executed if any of their dependencies have changed.

To change the state, we want to use the class properties directly instead of through a method like setState.

Since a code snippet is worth a thousand words, our final implementation looks like this:

const state = new State({
  count: 0,
  text: '',
});

state.observe(({ count }) => {
  console.log('Count changed', count);
});

state.observe(({ text }) => {
  console.log('Text changed', text);
});

state.count += 1;
state.text = 'Hello, world!';
state.count += 1;

// Output:
// Count changed 1
// Text changed Hello, world!
// Count changed 2
Go to full screen mode

Exit full screen

Implementation of the state class

Let’s start by creating a State class that accepts an initial state in its constructor and exposes an observe method that we will implement later.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = ();
  }

  observe(observer) {
    this.observers.push(observer);
  }
}
Go to full screen mode

Exit full screen

Here we choose to use an internal intermediary state object that allows us to hold the status values. We also store the observers in an internal observers array that will be useful when we complete this implementation.

Since these 2 properties are only used within this class, we can declare them as private with a little syntactic tweaking by prefixing them with a # and adding an initial declaration to the class:

class State {
  #state = {};
  #observers = ();

  constructor(initialState = {}) {
    this.#state = initialState;
    this.#observers = ();
  }

  observe(observer) {
    this.#observers.push(observer);
  }
}
Go to full screen mode

Exit full screen

In principle this would be a good practice, but we will use Proxies in the next step and they are not compatible with private properties. Without going into details and to make this implementation easier, we will use public properties for now.

Reading data from the status object using a proxy

When we sketched out the specifications for this project, we wanted to access the status values ​​directly on the class instance and not as an input into the internal state object.

For this we use a proxy object that is returned when the class is initialized.

As the name suggests, a proxy allows you to create an intermediary for an object to intercept certain operations, including getters and setters. In our case, we’re creating a proxy that exposes an initial getter that we can use to state input the object as if they belonged directly to the object State disposition.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = ();

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state(prop);
        }

        return target(prop);
      },
    });
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

const state = new State({
  count: 0,
  text: '',
});

console.log(state.count); // 0
Go to full screen mode

Exit full screen

Now we can define an initial state object when instantiating State and then extract the values ​​directly from that instance. Now let’s see how we can manipulate the data.

Add a setter to change the status values

We’ve added a getter, so the next logical step is to add a setter that allows us to manipulate the state object.

First we check if the key belongs to this object, then we check if the value has actually changed to avoid unnecessary updates, and finally we update the object with the new value.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = ();

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state(prop);
        }

        return target(prop);
      },
      set: (target, prop, value) => {
        if (prop in target.state) {
          if (target.state(prop) !== value) {
            target.state(prop) = value;
          }
        } else {
          target(prop) = value;
        }
      },
    });
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

const state = new State({
  count: 0,
  text: '',
});

console.log(state.count); // 0
state.count += 1;
console.log(state.count); // 1
Go to full screen mode

Exit full screen

We are now done reading and writing data. We can change the state value and then retrieve that change. So far our implementation is not very useful, so let’s implement observers.

Implementation of observers

We already have an array of the observers functions declared on our instance, so all we need to do is call them one by one whenever a value has changed.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = ();

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state(prop);
        }

        return target(prop);
      },
      set: (target, prop, value) => {
        if (prop in target.state) {
          if (target.state(prop) !== value) {
            target.state(prop) = value;

            this.observers.forEach((observer) => {
              observer(this.state);
            });
          }
        } else {
          target(prop) = value;
        }
      },
    });
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

const state = new State({
  count: 0,
  text: '',
});

state.observe(({ count }) => {
  console.log('Count changed', count);
});

state.observe(({ text }) => {
  console.log('Text changed', text);
});

state.count += 1;
state.text = 'Hello, world!';

// Output:
// Count changed 1
// Text changed 
// Count changed 1
// Text changed Hello, world!
Go to full screen mode

Exit full screen

Great, we now respond to data changes!

Small problem. If you’ve been paying attention up until now, we originally only wanted to run the observers when one of their dependencies changed. However, if we run this code, we see that each observer is executed whenever any part of the state changes.

But how then can we identify the dependencies of these functions?

Identifying Function Dependencies with Proxies

Once again, Proxies come to our aid. To identify the dependencies of our observer functions, we can use a proxy of our state object, execute them with the object as argument, and note what properties they accessed.

Simple, yet effective.

When we call observers, we just need to check if they depend on the updated property and only fire them if so.

Here is the final implementation of our mini library with this last component added. You will notice that the observers The array now contains objects that allow the dependencies of each observer to be preserved.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = ();

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state(prop);
        }

        return target(prop);
      },
      set: (target, prop, value) => {
        if (prop in target.state) {
          if (target.state(prop) !== value) {
            target.state(prop) = value;

            this.observers.forEach(({ observer, dependencies }) => {
              if (dependencies.has(prop)) {
                observer(this.state);
              }
            });
          }
        } else {
          target(prop) = value;
        }
      },
    });
  }

  observe(observer) {
    const dependencies = new Set();

    const proxy = new Proxy(this.state, {
      get: (target, prop) => {
        dependencies.add(prop);
        return target(prop);
      },
    });

    observer(proxy);
    this.observers.push({ observer, dependencies });
  }
}

const state = new State({
  count: 0,
  text: '',
});

state.observe(({ count }) => {
  console.log('Count changed', count);
});

state.observe(({ text }) => {
  console.log('Text changed', text);
});

state.observe((state) => {
  console.log('Count or text changed', state.count, state.text);
});

state.count += 1;
state.text = 'Hello, world!';
state.count += 1;

// Output:
// Count changed 0
// Text changed 
// Count or text changed 0 
// Count changed 1
// Count or text changed 1 
// Text changed Hello, world!
// Count or text changed 1 Hello, world!
// Count changed 2
// Count or text changed 2 Hello, world!
Go to full screen mode

Exit full screen

And there you have it, in 45 lines of code we’ve implemented a mini state management library in JavaScript.

To proceed

If we want to go further, we can add type suggestions using JSDoc or rewrite them in TypeScript to get suggestions about properties of the state disposition.

We could also add a unobserve method that would be exposed on an object returned by State.observe.

It may also be helpful to abstract the setter behavior into a setState method that allows us to modify multiple properties at once. Currently, we have to modify each property of our state one by one, which can trigger multiple observers if some of them share dependencies.

In any case, I hope you enjoyed this little exercise as much as I did and that it helped you dig a little deeper into the concept of Proxy in JavaScript.