DEV Community

Hannah La
Hannah La

Posted on

Create your own state management (part 1)

Prerequisites

In this article, it is assumed that you have already known React, Redux basic and/or other state management libraries.


When do we need to use a state management library?

When developing front-end with React, we usually run into situations that require one component to share its state and/or setState function to other components. There are methods to achieve this without a state management library, such as:

  • Passing state/setState functions as props from a parent component.
  • Using React's Context API + useContext hook.

However, sometimes the state format is not as vanilla as basic numeric/string, but a deeply-nested, complex object. In such cases, sometimes we don't need to update the whole object but only a few object properties. We then define some functions to manage how we update these states. However, to share these update logics, we have to either:

  • Pass these functions as props, along with the state.
  • Save these functions in context.
  • Pass state/setState as params to these functions to control update.
  • Use the useReducer hook to create Redux-like state management.

...But there are also cases where we want to restore a state, cache state, etc.

If your React application encounters these problems, it would be much better to use a state management library such as Redux, Mobx or (highly recommended to try out) Zustand. The methods in this article for creating state management should only be used for learning purposes.

A Redux like state-management, from scratch.

With the introduction of React Hooks, useReducer has been described as an advanced alternative of useState which imitates the implementation from Redux.

Let's imagine that we have a to-do list application in React similar to the following one.

//List of to-do tasks
const listItems = [
    {
        id: 1
        isDone: false,
        description: "Clean kitchen"
    },
    {
        id: 2
        isDone: false,
        description: "Buy grocery"
    },
    {
        id: 3
        isDone: true,
        description: "Fix the light bulb"
    }
];

//To-do list item component
const TodoListItem = (props) => {
    return (
        <div className="todo-item">
            <input type="checkbox" name={id} checked={props.isDone}/>
            <p>{props.description}</p>
        </div>
    );
}

//To-do list item component
const TodoWidgetListItem = (props) => {
    return (
        <div className="todo-widget-item">
            <input type="checkbox" name={id} checked={props.isDone}/>
            <p>{props.description}</p>
        </div>
    );
}

//To-do list component
const TodoList = (props) => {
    const [_printout, _setPrint] = React.useState('');    

    React.useEffect(() => {
        const doneTasks = props.listItems.filter((item) => item.isDone);
        _setPrint(`You have done ${doneTasks.length} task(s).`);
    }, [props.listItems]);

    return (
        <div className="card-panel">
            <div id="todo-list">
                {props.listItems.map((item) => {
                    return <TodoListItem {...item}/>
                })}
                <p>{_printout}</p>
            </div>
            <div id="todo-widget">
                {props.listItems.map((item) => {
                    return <TodoWidgetListItem {...item}/>
                })}
            </div>

        </div>
    );
}

const TodoView = () => {
    const [_list, _updateList] = React.useState(listItems);

    return (
        <div>
            <TodoList listItems={_list}/>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

In the TodoList component, we want _printout state to watch and preserve the total number of finished tasks. This means that when we update isDone property of a list item, this should trigger _list to update, then _printout should be noticed of this update and get the current number of finished tasks. As mentioned above, in order to allow TodoListItem to update _list, we have to pass both the state _list and the update state function _updateList to TodoListItem component. (To make this simple, we will pass the update state function as a prop).


//To-do list item component
const TodoListItem = (props) => {
    //We use the state update function to trigger changes to the `_list` state
    const onChangeUpdateListItem = (e) => {
        const {updateListItems, listItems, id} = props;

        const index = listItems.findIndex((item) => item.id === id);
        listItems[index].isDone = e.currentTarget.checked;

        //Trigger changes in _list
        updateListItems(listItems);
    }

    return (
        //...
            <input type="checkbox" name={id} checked={props.isDone}
                    onChanges={onChangeUpdateListItem}/>
       // ...
    );
}

//To-do list component
const TodoList = (props) => {
    //...
    return (
        <div className="card-panel">
            {props.listItems.map((item) => {
                return <TodoListItem {...item} 
                        listItems={props.listItems}
                        updateListItems={props.updateListItems}/>
            })}
            <p>{_printout}</p>
        </div>
    );
}

const TodoView = () => {
    const [_list, _updateList] = React.useState(listItems);

    return (
        <div className="card-panel">
            <TodoList listItems={_list} updateListItems={_updateList}/>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Whenever the user clicks on a task's checkbox, onChangeUpdateListItem will update the _list state, and the number of tasks done will be recalculated and displayed. However, with this to-do application, we also want TodoWidgetListItem to be able to update isDone status with the same logic. A way to resolve this is to declare onChangeUpdateListItem in the parent component TodoList and pass it down. However, if you want additional logic to be shared between these components, passing multiple set state functions as props isn't a good idea. One of the better approaches is to use useReducer, which implementation is similar to Redux.

According to React's doc, the syntax for useReducer:

const [state, dispatch] = useReducer(reducer, initialArg, initFunction);
Enter fullscreen mode Exit fullscreen mode

where

  • reducer is a reducer function.
  • initFunction (optional) is the function that initialize the state, using initialArg as the parameter
  • initialArg is the initial state or parameter object to initFunction if we want to use initFunction.
  • dispatch is the function to dispatch an operation, which takes an action object as parameter.

The reducer function format should be:

/**
* @param state - The current state
* @param action - Operation specification
*/
const reducer = (state, action) => {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Usually, action type can be anything in React - React currently doesn't have type bound to action, instead it allows you to customize action type to suit your application. In this article, we assume that action param takes the following format

action: {
    name: string //Name of the operation
    payload: {[key: string]: any} //The params require to be passed to the operation functions
}
Enter fullscreen mode Exit fullscreen mode

After understanding how useReducer works, we define our reducer function:

/**
* Our reducer function. Should always return a new state
* @param state - List items, similar to _list
* @param action - Operation specification. 
*/
const listReducer = (state, action) => {
    switch (action.name){
        case "updateIsDone":
            const {id, isDone} = action.payload;
            const index = state.findIndex((item) => item.id === id);
            state[index].isDone = isDone;

            //Return the state
            return state;
        default:
            return state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can declare a useReducer at the top level.

const TodoView = () => {
    const [_list, _updateList] = React.useReducer(listReducer, listItems);

    return (
        //...
            <TodoList listItems={_list} updateListItems={_updateList}/>
        //...
    )
}
Enter fullscreen mode Exit fullscreen mode

Then, we can use the dispatch function to apply changes to _list state in our TodoWidgetListItem and TodoListItem:

//To-do list item component
const TodoListItem = (props) => {
    return (
        //...
            <input type="checkbox" name={id} checked={props.isDone}
                    onChange={(e) => props.updateListItems({
                        name: 'updateIsDone',
                        payload: {
                            id: props.id,
                            isDone: e.currentTarget.checked
                        }
                    })}/>
        //...
    );
}

//To-do list item component
const TodoWidgetListItem = (props) => {
    return (
        //...
            <input type="checkbox" name={id} checked={props.isDone}
                    onChange={(e) => props.updateListItems({
                        name: 'updateIsDone',
                        payload: {
                            id: props.id,
                            isDone: e.currentTarget.checked
                        }
                    })}/>
        //...
    );
}
Enter fullscreen mode Exit fullscreen mode

The advantage of using useReducer is we can add additional operations without worrying about passing these operations down to our children - we've already created a single source to store our state and all necessary operations. All we need to do is add a new operation to our reducer function.

//Always remember to return a new state for each operation
const listReducer = (state, action) => {
    switch (action.name){
        case "updateIsDone":
            //...
            return state;
        case "newOperation":
            //...
            return state;
        case "newOperation2":
            //...
        default:
            return state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Our state management is almost there.

Let's have a thought of how we pass the state and dispatch function in our example application. The state and dispatch function are declared in the TodoView component, then we pass them down to TodoList as props, then from there we pass them as props to TodoListItem and TodoWidgetListItem. It is easily noticed that TodoList doesn't actually use the reducer function, which makes the function redundant to the TodoList. It would be so much better if we can get the state and dispatch function wherever we like and doesn't need to pass these as props. Luckily, React also introduced the useContext hook to do that for us.

We first create the context via React's context API with a state and a dispatch function.

//Our little store
const ListContext = React.createContext({
    state: [],
    dispatchFunction: () => {}
});
Enter fullscreen mode Exit fullscreen mode

Then we wrapped our TodoView with the context

const TodoView = () => {
    ...

    return (
        <ListContext.Provider value={{
            state: _list,
            dispatchFunction: _updateList
        }}>
            <div className="card-panel">
                <TodoList/>
            </div>
        </ListContext.Provider>
    )
}
Enter fullscreen mode Exit fullscreen mode

In our TodoWidgetListItem and TodoListItem, we get the dispatch function by using useContext hook instead of getting it via props.

//To-do list item component
const TodoListItem = (props) => {
    const {dispatchFunction} = useContext(ListContext);
    return (
        //...
            <input type="checkbox" name={id} checked={props.isDone}
                    onChange={(e) => dispatchFunction({
                        name: 'updateIsDone',
                        payload: {
                            id: props.id,
                            isDone: e.currentTarget.checked
                        }
                    })}/>
        //...
    );
}

//To-do list item component
const TodoWidgetListItem = (props) => {
    const {dispatchFunction} = useContext(ListContext);
    //...
}
Enter fullscreen mode Exit fullscreen mode

Yes, we did it - we've just created our own Redux from scratch.
However, there are still lots of problems with our managing states approach. We still have a lot of issues with our example application. A simple problem is when we have multiple stores. With our approach, we might need to have a lot of wrappers around our TodoView, which might end up like this:

const TodoView = () => {
    //...

    return (
        <ListContext.Provider>
            <Store1.Provider>
                <Store2.Provider>
                    ...
                    <div className="card-panel">
                        <TodoList/>
                    </div>
                    ...
                </Store2.Provider>
            </Store1.Provider>
        </ListContext.Provider>
    )
};
Enter fullscreen mode Exit fullscreen mode

In part 2, we'll discuss how we can remove Context API and create our own useContext.

Resource

React hooks API reference, https://reactjs.org/docs/hooks-reference.html
React Context API reference, https://reactjs.org/docs/context.html

Top comments (0)