- Build a state management system with vanilla JavaScript
- Originally by ANDY BELL
- The Nuggets translation Project
- Permanent link to this article: github.com/xitu/gold-m…
- Translator: Shery
- Proofreader: IridescentMia coconilu
State management is not new in software, but it is still relatively new in javascript-built applications. Traditionally, we keep state directly in the DOM and even assign it to global objects in the window. But now we have a number of options, libraries and frameworks that can help us manage state. Libraries like Redux, MobX, and Vuex make it easy to manage cross-component state. It greatly increases the extensibility of applications, and it is useful for state-first reactive frameworks such as React or Vue.
How do these libraries work? What if we wrote our own state management? It turns out to be pretty simple, and gives you the opportunity to learn some very common design patterns, as well as some modern apis that are both useful and usable.
Before we begin, make sure you have some knowledge of intermediate JavaScript. You should understand data types and, ideally, you should have a grasp of some of the more modern ES6+ syntax features. If not, this can help you. It’s worth noting that I’m not saying you should use this instead of Redux or MobX. We’re working on a small project together to improve skills, and hey, if you care about JavaScript file size, it can really handle a small application.
An introduction to
Before we dive into the code, let’s take a look at what we’re developing. It is a “to do list” of what you have accomplished today. It magically updates various elements of the UI without relying on the framework. But it’s not really magic. Behind the scenes, we already have a little state system that waits for instructions and maintains single-source data in a predictable way.
View the demo
Look at the warehouse
Cool, right? Let’s do some configuration first. I’ve put together some templates so that we can keep this tutorial simple and fun. The first thing you need to do is clone it from GitHub, or download and unzip its ZIP file.
Once you have downloaded the template, you need to run it on your local Web server. I like to use a package called HTTP-Server for these things, but you can use whatever you want. When you run it locally, you’ll see something like this:
The initial state of our template.
Establish project structure
Open the root directory with your favorite text editor. For me this time, the root directory is:
~/Documents/Projects/vanilla-js-state-management-boilerplate/
Copy the code
You should see something like this:
/ SRC ├─.├ ─.├ ── ├─ ├─.├.mdCopy the code
Publish/subscribe
Next, open the SRC folder and go to the js folder inside. Create a new folder called lib. Inside, create a new file called pubsub.js.
Your js directory structure should look like this:
/js ├── pubsub.jsCopy the code
Open pubsub.js since we are going to create a small Pub/Sub mode (publish/subscribe mode). We are creating functionality that allows other parts of the application to subscribe to named events. Another part of the application can then publish these events, often with some associated payload.
Pub/Sub is sometimes difficult to master, what about an example? Suppose you work at a restaurant and your customer orders a starter and main course. If you’ve ever worked in a kitchen, you know that when waiters clean the starters, they let the chef know which table has been cleared. Here’s a hint for the main course at that table. In a big kitchen, some chefs may be preparing different dishes. They subscribe to the waiter’s notification that the customer has finished the starter, so they know to prepare the main course themselves. So, you have multiple cooks subscribed to the same prompt (named event) and do different things when they receive the prompt (callback).
Hopefully that makes sense. Let’s move on!
The PubSub pattern iterates over all subscriptions and triggers their callbacks, passing in the associated payloads. This is a great way to create a very elegant and responsive flow for your application that we can do with just a few lines of code.
Add the following to pubsub.js:
export default class PubSub {
constructor() { this.events = {}; }}Copy the code
We get a brand new class, and we set this.events to empty by default. The this.Events object will hold our named events.
After the closing parenthesis of the constructor function, add the following:
subscribe(event, callback) {
let self = this;
if(! self.events.hasOwnProperty(event)) { self.events[event] = []; }return self.events[event].push(callback);
}
Copy the code
This is our subscription method. You pass a unique string event as the event name and a callback function for that event. If we don’t already have a matching event in our Events collection, we create it with an empty array so we don’t have to type check it later. We then add the callback to the collection. If it already exists, the callback is added directly to the collection. We return the length of the set of events, which is handy for people who want to know how many events exist.
Now that we have the subscription method, guess what we’re going to do next? You know: the publish method. Add the following after your subscription method:
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 there are any incoming events in our event collection. If not, we return an empty array. There is no suspense. If there is an event, we iterate over each stored callback and pass data to it. If there’s no callback (which shouldn’t happen), it’s fine, because we created the event with an empty array in the SUBSCRIBE method.
This is PubSub mode. Let’s move on to the next section!
Store object (core)
Now that we have the Pub/Sub module, the Store class, the core module of our little application, has its only dependency. Now let’s start refining it.
Let’s first outline what it does.
Store is our core object. Whenever you see @import store from’.. At /lib/store.js, you’ll introduce the object we’re going to write. It will contain a state object, which in turn contains our application state, a COMMIT method that will call our >mutations, and finally a dispatch function that will call our Actions. Between this application and the core of the Store object, there will be an agent-based system that will monitor and broadcast state changes using our PubSub module.
Start by creating a new directory called Store in the JS directory. From there, create a new file called store.js. Your js directory should now look like this:
/ js └ ─ ─ lib └ ─ ─ pubsub. Js └ ─ ─ store └ ─ ─ store. JsCopy the code
Open store.js and import our Pub/Sub module. To do this, add the following at the top of the file:
import PubSub from '.. /lib/pubsub.js';
Copy the code
This will be very familiar to those who use ES6 regularly. However, running this code without a packaging tool may not be easily recognized by the browser. There is already a lot of browser support for this approach!
Next, let’s start building our object. After importing the file, add the following directly to store.js:
export default class Store {
constructor(params) {
letself = this; }}Copy the code
This is all clear, so let’s add the next item. We will add default objects for state, Actions, and mutations. We also added a status attribute that we will use to determine what the object is doing at any given time. This is let self = this; At the back of the:
self.actions = {};
self.mutations = {};
self.state = {};
self.status = 'resting';
Copy the code
After that, we’ll create a new PubSub instance that will be the value of the Events property of the store:
self.events = new PubSub();
Copy the code
Next, we will search the incoming Params object to see if any Actions or mutations were passed in. When we instantiate the Store object, we can pass in a data object. These include collections of Actions and mutations, which control the flow of data in our store. Add the following code after the last line you added:
if(params.hasOwnProperty('actions')) {
self.actions = params.actions;
}
if(params.hasOwnProperty('mutations')) {
self.mutations = params.mutations;
}
Copy the code
This is all of our defaults and almost all of our potential parameter Settings. Let’s look at how our Store object keeps track of all changes. We will use a Proxy to do this. The main thing a Proxy does is Proxy state objects. If we add a GET interceptor method, we can monitor each time we ask for object data. Similar to the set interception method, we can keep an eye on changes made to the object. That’s the main part we’re interested in today. Add the following after the last line of code you add, and we’ll discuss what it’s doing:
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
This part of the code says we are capturing the state object set operation. This means that when mutation runs something like state.name =’Foo’, the interceptor catches it before it is set, giving us an opportunity to process the change or even reject it altogether. But in our context, we will set up the changes and then log them to the console. We then publish a stateChange event using the PubSub module. Any callbacks subscribed to the event will be invoked. Finally, we check the state of the Store. If it is not currently a mutation, it may mean that the status was manually updated. We added a little warning in the console to give the developers a hint.
There’s a lot going on here, but I hope you’re starting to see how it all fits together, and importantly, how we’re able to maintain state centrally, thanks to Proxy and Pub/Sub.
Dispatch and commit
Now that we’ve added the core of the Store, let’s add two methods. One is the dispatch that will call our Actions and the other is the COMMIT that will call our mutation. Let’s start with dispatch and add this method after constructor in store.js:
dispatch(actionKey, payload) {
let self = this;
if(typeof self.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 here is to find the action and, if it exists, set the state and invoke the action, while creating logging groups to keep all our logs clean and tidy. Anything recorded (such as mutation or Proxy logs) will remain in the group we defined. If no action is set, it logs an error and returns false. This is very simple, and the COMMIT method is much more straightforward.
After the Dispatch method add:
commit(mutationKey, payload) {
let self = this;
if(typeof self.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
This approach is very similar, but we need to understand the process for ourselves anyway. If mutation can be found, we run it and get the new state from its return value. We then merge the new state with the existing state to create our latest version of state.
With these methods added, our Store object is almost complete. You can modularize the application now if you like, because we’ve added most of the features we need. You can also add tests to check that everything works as expected. I’m not going to end this article like this. Let’s do what we set out to do and keep refining our little application!
Creating the base Components
To communicate with our Store, we have three main areas that are independently updated based on what is stored in them. We will list the submitted items, a visual count of those items, and another one that is visually hidden to provide more accurate information for screen readers. These all do different things, but they all benefit from sharing something to control their local state. We’re going to make a basic component class!
First, let’s create a file. In the lib directory, go ahead and create a file named Component.js. My file path is:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/lib/component.js
Copy the code
After creating the file, open it and add the following:
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 talk about this code. First, we need to import the Store class. This is not because we want an instance of it, but rather as a property in constructor. Speaking of which, in constructor we need to see if we have a render method. If the Component class is a parent of another class, it might set its own methods for Render. If no method is set, we create an empty method in case things go wrong.
After that, we check the Store class as mentioned above. We do this to ensure that the Store property is an instance of the Store class so that we can safely use its methods and properties. Speaking of which, we subscribe to the global stateChange event, so our object can be responsive. The Render function is called each time the state changes.
That’s all we need to write for this class. It will be used as the parent of the other component classes extend. Let’s do it together!
Create our component
As I said earlier, we’ll complete three components that extend from the base Component class, via the extend keyword. Let’s start with the biggest component: the list of items!
In your JS directory, create a new folder called Components, then create a new file called list.js. My file path is:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/components/list.js
Copy the code
Open the file and paste this whole code into it:
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 = `<p class="no-items">You've done nothing yet 😢 `; return; } self.element.innerHTML = `
${store.state.items.map(item => { return `
- ${item}
')} `; self.element.querySelectorAll('button').forEach((button, index) => { button.addEventListener('click', () => { store.dispatch('clearItem', { index }); }); }); }};Copy the code
I hope this code is self-explanatory to you after the previous tutorial, but we’ll talk about it anyway. We start by passing the Store instance to our inherited Component parent class. The Component class we just wrote.
After that, we declare the Render method, which is called every time a Pub/Sub’s stateChange event is triggered. In the Render method, we generate a list of items, or a notification when there are no items. You’ll also notice that each button is attached with an event, and they trigger an action, which is then handled by our store. This action doesn’t exist yet, but we’ll be adding it soon.
Next, create two more files. These are two new components, but they’re small — so we’ll just paste some code into them and move on.
First, create count.js in your Component directory and paste in the following:
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 ${store.state.items.length} thing${suffix} today ${emoji} `; }}Copy the code
Does this look similar to the list component? There’s nothing here that we haven’t covered yet, so let’s add another file. Add the status.js file to the same components directory and paste in the following:
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
As before, there’s nothing here that we haven’t covered yet, but you can see how convenient it is to have a base class Component, right? This is one of the many benefits of object-oriented programming, and is the basis for much of this tutorial.
Finally, let’s check that the JS directory is correct. Here’s the structure of where we are right now:
/ SRC ├ ─ ─ js │ ├ ─ ─ components │ │ ├ ─ ─ count. Js │ │ ├ ─ ─ a list. The js │ │ └ ─ ─ status. The js │ ├ ─ ─ lib │ │ ├ ─ ─ component. The js │ │ └ ─ ─ pubsub. Js └ ─ ─ ─ ─ ─ store └ ─ ─ store. Js └ ─ ─ main. JsCopy the code
Let’s connect the dots
Now that we have the front end component and the main Store, all we have to do is hook it all up.
We’ve got the Store system and components rendering and interacting with data. Now let’s connect the two separate parts of the application to make the whole project work together. We need to add an initial state, some actions and some mutations. In the Store directory, add a new file called state.js. My file path is:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/store/state.js
Copy the code
Open the file and add the following:
export default {
items: [
'I made this'.'Another thing']};Copy the code
The meaning of this code is self-evident. We are adding a set of default projects so that our applet will be fully interactive the first time it loads. Let’s go ahead and add some actions. In your store directory, create a new file called actions.js and add the following to it:
export default {
addItem(context, payload) {
context.commit('addItem', payload);
},
clearItem(context, payload) {
context.commit('clearItem', payload); }};Copy the code
There are very few actions in this application. In essence, each action passes the payload (associated data) to the mutation, which in turn commits the data to the Store. As we’ve seen before, the context is an instance of the Store class, and payload is the payload that’s passed in when the action is triggered. Speaking of mutations, let’s add something. Add a new file named mutation. Js in the same directory. Open it and add the following:
export default {
addItem(state, payload) {
state.items.push(payload);
return state;
},
clearItem(state, payload) {
state.items.splice(payload.index, 1);
returnstate; }};Copy the code
As with Actions, these mutations are rare. In my opinion, your mutations should be kept simple, because they have a job: change the store state. Thus, these examples are as simple as they start out. Any proper logic should be in your actions. As you can see in this system, we return the new version of state so that the Store’s commit method can work its magic and update everything. With this, the main building blocks of the Store system are in place. Let's combine them through the index file.
In the same directory, create a new file named index.js. Open it and add the following:
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
This file imports all of our Store modules and combines them together as a concise Store instance. Mission accomplished!
The final piece of the puzzle
The last thing we need to do is add the main.js file contained in the waaaay page index.html at the beginning of this tutorial. Once we sorted this out, we were able to launch the browser and enjoy our hard work! Create a new file named main.js under the root of the js directory. Here is my file path:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/main.js
Copy the code
Open it and add the following:
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
So far, all we’ve done is get the dependencies we need. We’ve got the Store, our front-end component, and a few DOM elements. We then added the following code to make the form interact directly:
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
What we’re doing here is adding an event listener to the form and preventing it from submitting. Then we get the value of the text box and trim the Spaces at both ends of it. We do this because we want to check to see if anything is passed to the Store next. Finally, if there’s content, we’ll use that content as payload to trigger our addItem action and let our shiny new store handle it for us.
Let’s add some more code to main.js. Under the event listener, add the following:
const countInstance = new Count();
const listInstance = new List();
const statusInstance = new Status();
countInstance.render();
listInstance.render();
statusInstance.render();
Copy the code
All we do here is create new instances of the components and call each of their Render methods so that we get the initial state on the page.
With the final addition, we’re done!
Open your browser, refresh and bask in the glory of the new state management application. Go ahead and add something like ** “Complete this awesome tutorial” **. Neat, isn’t it?
The next step
You can do a lot of things with the little system we put together. Here are some ideas to explore further yourself:
- You can implement some local storage to maintain state even when you reload
- You can isolate the front-end module to provide a small state system for your project
- You can continue to develop the front-end module of this application and make it look great. (I really want to see your work, so please share!)
- You can use some remote data, you can even use an API
- You can organize what you’ve learned about
Proxy
And Pub/Sub mode knowledge, and further learn those skills that can be used for different jobs
conclusion
Thank you for learning how state systems work with me. The big mainstream state management libraries are far more complex and intelligent than what we do — but it’s still useful to understand how these systems work and demystify what’s behind them. In any case, it’s also useful to understand the power of JavaScript without using a framework.
If you want a finished version of this little system, check out the GitHub repository. You can also view a demo here.
I’d love to see it if you build on it, so if you do, please contact me on Twitter or in the comments below!
If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.