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>
)
}
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>
)
}
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);
where
-
reducer
is a reducer function. -
initFunction
(optional) is the function that initialize the state, usinginitialArg
as the parameter -
initialArg
is the initial state or parameter object toinitFunction
if we want to useinitFunction
. -
dispatch
is the function to dispatch an operation, which takes anaction
object as parameter.
The reducer function format should be:
/**
* @param state - The current state
* @param action - Operation specification
*/
const reducer = (state, action) => {
...
}
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
}
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;
}
}
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}/>
//...
)
}
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
}
})}/>
//...
);
}
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;
}
}
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: () => {}
});
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>
)
}
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);
//...
}
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>
)
};
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)