DEV Community

Muhammad Ali Naeemi
Muhammad Ali Naeemi

Posted on

How state management works (roughly)

Alternative title: How to fail at explaining why you should use state manager

We have all heard about state managers in frontend. Maybe used one or two as well, either out of necessity or requirement from your senior developer or maybe "hyyyype!".
But have you ever thought how they are useful? Why it is recommended to use a state manager where it is possible?

Unfortunately, most of the "tutorials" out there just talk about "YOU SHOULD USE A STATE MANAGER AND HOW TO CREATE A NOTORIOUS COUNTER" but always fails to explain why to use it! And what exactly is happening in the background.

In this episoooode of "Rants with Ali", we will "try" to dive a little into this void and try to create our own very basic state manager. And see how state functions in the background. We will not discuss why you should use state managers. By reading this document, you can get an answer to "why", but if you are still in doubt, you can ping me

Before we begin to create our V100000x_MEGA_AWESOME_STORE, we will go through with some basics, as roughly as "sanely" possible. No super duper definitions from wiki boom boom.

Btw, the example at the end doesn't follow any "specific" state managing library for a very good reason. Go figure!

Store

It's a mega container, which:

  • contains state of your application (see below)
  • has ways to mutably or immutably modify your state
    • As stupid as "modify" and "immutable" sounds together, that is the truth of reality
    • Yes, mayyybe..we will get into how some state managers can handle immutability in JS
    • For example:
      • Vuex mutates
      • Redux immutably provides a new state
      • Arguably, both are doing almost the same thing
  • can have accessors to access parts of your state

    • In a class like syntax, think of them like "getters"
    class Something {
        constructor(obj) {
            this.obj = obj;
        }
    
        get name () {
            return this.obj.deeply.nested.name;
        }
    }
    
    const testObject = { deeply: { nested: { name: 'ali' } } };
    let a = new Something(testObject);
    
    console.log(a.name); // ali - watermark level pikacu!
    
  • has actions, that..as the name suggests, does "x" (even asynchronously) and can modify the state

State

In plain english, it's just a container that:

  • has some properties attached to it
  • is immutable in some state managers (duh)
  • has maybe a few more things, but basically your data that you want to be shared in your components etc
const defaultState = {
    a: '',
    default: 'xD',
    state: true,
    nested: {
    values: 'yep',
        are_also: '...',
        possible: '🤯🤯'
    }
}
Enter fullscreen mode Exit fullscreen mode

Actions

These are functions that:

  • have access to your state
  • have a predictable "key" as either their name or return this "key" as type, so they can be globally "predictably" called/dispatched (see below)
  • can ultimately end up modifying your data. Either through reducers or commits
  • are, I guess, only a piece of thingies, that can be asynchronous
    • technically other parts of the store can be, but this is usually where you want to put your async stuff.
    • Trust papa!
// action type
const INCREMENT = 'INCREMENT'; // THIS AN BE LOWERCASE OR lIkEThiS. but would you!?
const DECREMENT = 'DECREMENT';

const actions = {
    [INCREMENT]: async (...) => {...},
 [DECREMENT]: (...) => {...}
}
Enter fullscreen mode Exit fullscreen mode

Reducer & Commits

Well, these are what ultimately modify your state, mutably or immutably

  • they receive usually:
    • a payload (data)
    • if a reducer, then an action type, so it knows which part of state is being modified and has logic accordingly
    • otherwise in commit scenario, the party that commits knows which commit method to call

Let's Build it now...

  • Firstly, we will create a generic store
  • Read above if you have difficulty grasping what is going on here. I invested my time (that nobody asked for) writing those for a reason you know..
  • filename: 'store_factory.js'

Creating a generic store factory

class Store {
    /** @typedef {Object} State */

    /** @type {State} */
    #_state;
    constructor(state, actions, reducer, commits) {
        this.#_state = state;
        this._actions = actions;
        this._reducer = reducer;
        this._commits = commits;
    }

    /**
     * @param {String} action
     * @param {...*} payload
     * @returns {Promise<*>} returns the result of action
     */
    async dispatch (action, ...payload) { // this has side effects
        if (this._actions[action]) {
            const actionData = await this._actions[action](...payload);
            this.#_state = this._reducer( this.#_state, actionData );
            return actionData;
        }
    }

    /**
   * @param {String} commitType
   * @param {*} payload  
     */
    commit(commitType, payload) {
        if (this._commits[commitType]) {
            this.#_state = this._commits[commitType](this.#_state, payload);
        }
    }

    /**
     * @returns {State}
     */ 
    getState() {
        return this.#_state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Creating a store that will use the store factory

  • imagine you have, filename: 'userinfo_store.js'
  • First we will create a default state.
    • Every state must have a default state to make sure that validity of store and to also provide a footprint

Default state

const defaultState = {
    name: '',
    age: {
        day: null,
        month: null,
        year: null
    }
}
Enter fullscreen mode Exit fullscreen mode

Actions

  • Then we define our actions, that can help us modify our state
  • We will first define global identifiers
  • Afterwards, we will use these "global identifiers" to use as action's method key or their name
  • We do this, because having a unified place to define name of your method, can be used anywhere in your codebase without having to worry about their name change.
    • When we write a reducer you will have even better understanding to why defining it like this makes sense
  • Plus, you get intellisense in good IDEs / editors. gth sublime!
// our actions global identifiers
const ACTIONS = {
    CHANGE_NAME: 'CHANGE_NAME', 
    CHANGE_DAY: 'CHANGE_DAY',
    GET_DATA: 'GET_DATA'
}

// action methods that uses the global identifiers as their key/name
const actions = {
    [ACTIONS.CHANGE_NAME] (name) {
        return {
            type: ACTIONS.CHANGE_NAME, // this is used by reducer o
            payload: name
        }
    },
    [ACTIONS.CHANGE_DAY] (day) {
        return {
            type: ACTIONS.CHANGE_DAY,
            payload: { day } // sending object for the sake of example and completion
        }
    },
    async [ACTIONS.GET_DATA] () {
        return {
            type: ACTIONS.GET_DATA,
            payload: {
                name: 'ali',
                age: {
                    day: 28,
                    month: 6,
                    year: 1990
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Reducers

  • Now we will create our reducer (maybe in the same file, it's up to you how to structure your codebase! )
  • You will see that in each case, we always return state.
    • This is because reducer takes whatever comes through action, and reduces it to one thing; that is, "State"!
  • Another thing you will notice is that we use destructure when returning our state.
    • This is to ensure, that we will have consistent viable state, despite anything that is sent to modify it.
    • This provides stability, as well as ensuring that any input will consistently have same output!
    • Destructing makes sure that we always return "copy" of modified data and not the same reference. Remember, that in javascript objects are passed by reference!
    • as such, it helps in immutability!
  • We are not doing JSON.stringify "hack" because that is very inefficient. And we want to keep the same reference for unchanged nested objects
  • In reducers, we usually use switch/case. You can use if/else if but in large store, we want things to be fast and switch/case is definitely faster than other alternatives.
  • In these switch cases, we use the name of our action and having "global identifiers" as action names for lookups is helping us here!
const reducer = (state, action) => {
    switch (action.type) {
        case ACTIONS.CHANGE_NAME:
            return {
                ...state,
                name: action.payload
            }

        case ACTIONS.CHANGE_DAY:
            return {
                ...state,
                age: {
                    ...state.age,
                    ...action.payload
                }
            }

        case ACTIONS.GET_DATA:
            return {
                ...state,
                ...action.payload
            }

        default:
            return state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Lets's also create Commits

const COMMIT_TYPES = { // kind of same way of writing as actions
  CHANGE_MONTH: 'CHANGE_MONTH'
}

const commits = {
    [COMMIT_TYPES.CHANGE_MONTH] (state, monthData) {
        return {
            ...state,
            age: {
                ...state.age,
                month: monthData
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Instantiating our store and using it!

  • First, lets define our component that would use our store
class BaseComponent {
    constructor(store) {
        this.store = store;
    }

    get state() { // think of these "get" as "computed" in vuejs, for example
        return this.store.getState();
    }

}

// parent
class MyComponent extends BaseComponent {
    constructor(store) {
        super(store); // we instantiate our base component
        this.children = [];
    }

    get name() {
        // this.state is coming from "BaseComponent". Learn OOP please?
        return this.state.name;
    }

    async fetchData() {
        return this.store.dispatch(ACTIONS.GET_DATA);
    }

    changeName(name) {
        return this.store.dispatch(ACTIONS.CHANGE_NAME, name);
    }

    addChildComponent(component) {
        this.children.push(new component(this.store));
    }
}

// child. I will not create a 
class ChildComponent extends BaseComponent {
    constructor(store) {
        super(store);
    }

    get ageDay() {
        return this.state.age.day;
    }

    get ageMonth() {
        return this.state.age.month;
    }

    changeDay(day) {
        this.store.dispatch(ACTIONS.CHANGE_DAY, day);
    }
}

Enter fullscreen mode Exit fullscreen mode
  • No let's instantiate our store and create a few components with these stores
  • We will also add some child component to a parent component
  • This should help us see how easy it is to share data between components. Be it sibling components, child components or components from another galaxy!
// we create one store outside of any context
// so it can be used by any component
const myStore = new Store(defaultState, actions, reducer, commits);

// now we utilize out component and use our store
(async () => {
    const componentA = new MyComponent(myStore);
    const componentB = new MyComponent(myStore);
    // here our third component is using a different state from same store footprint
    const componentC = new MyComponent(
        new Store({...defaultState, name: 'naeemi'}, actions, reducer, commits)
    );

    // let's also add a child component inside component B
    componentB.addChildComponent(ChildComponent);

    // component A and B share the same state
    console.log({
        componentA_name: componentA.name, 
        componentA_state: componentA.state,
        componentB_name: componentB.name,
        componentB_child_age_day: componentB.children[0].ageDay,
        componentB_child_age_month: componentB.children[0].ageMonth,
        componentC_name: componentC.name
    });

    await Promise.all([
        componentA.fetchData(),
        componentC.fetchData()
    ]);

    // component A and B share the same state
    // the footprint of fetchData is same, so component is also getting same kindish value
    console.log({
        componentA_name: componentA.name, 
        componentA_state: componentA.state,
        componentB_name: componentB.name,
        componentB_child_age_day: componentB.children[0].ageDay,
        componentB_child_age_month: componentB.children[0].ageMonth,
        componentC_name: componentC.name
    });

    // let's change the name from componentA 
    // and se how it will also affect componentB, since they both share state
    await componentA.changeName('Sensei');

    // let's also change age from componentB's child
    // this child is also sharing the same state as componentA and componentB
    await componentB.children[0].changeDay(5);

    // we will commit from outside of a component, through store attached to it
    // for the sake of example
    componentB.store.commit(COMMIT_TYPES.CHANGE_MONTH, 4);

    // component A and B share the same state
    console.log({
        componentA_name: componentA.name, 
        componentA_state: componentA.state,
        componentB_name: componentB.name,
        componentB_child_age_day: componentB.children[0].ageDay,
        componentB_child_age_month: componentB.children[0].ageMonth,
        componentC_name: componentC.name
    });
})()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)