State management in React without 3rd party libraries? React hooks can help you do just that! 👌
In this tutorial, we will cover how you can do global state management with React only. No need for other external libraries. We will use React hooks and the context API.
The context API allows you to share your state across a tree of React components.
We will take advantage of the useContext and useReducer hooks to manage the global state. The pattern described here is like the Redux pattern. You create reducers and dispatch actions to update the state.
Ready? Let's do this! 🔥
Let's have a look at the bigger picture first and then dig deeper.
I have created a small react project to show different data fetching patterns. The project has a standard create-react-app structure. 👇
├── README.md
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── actions
│ │ ├── index.js
│ │ └── types.js
│ ├── components
│ │ ├── PostsList.js
│ │ ├── PostListFromContext.js
│ │ └── WithContext.js
│ ├── contexts
│ │ ├── index.js
│ │ └── PostsContexts.js
│ ├── index.css
│ ├── index.js
│ ├── reducers
│ │ ├── posts.js
│ │ └── index.js
│ ├── serviceWorker.js
└── yarn.lock
Let's dig in! 🤓
We will start by looking into the contexts.
You can think of contexts in this case as a replacement to Redux's store.
We first need to create a StateProvider (like a store provider). Also, we need a useStateFromContext hook. The useStateFromContext hook returns the global state and a dispatch function.
// contexts/index.js
import React, { createContext, useContext, useReducer } from 'react';
import PropTypes from 'prop-types';
export const StateContext = createContext();
export const StateProvider = ({ reducer, initialState, children }) => {
return (
<StateContext.Provider value={useReducer(reducer, initialState)}> {/* useReducer returns the state and a dispatch function to update state */}
{children}
</StateContext.Provider>
)
};
StateProvider.propTypes = {
/**
* @return {React.Node}
*/
children: PropTypes.node.isRequired,
/**
* @desc Initial state value.
*/
initialState: PropTypes.shape({}).isRequired,
/**
* @desc The reducer's state & actions update to context's data.
* @param {object} state
* @param {object} action
*/
reducer: PropTypes.func.isRequired
};
export const useStateFromContext = () => useContext(StateContext);
To initialize a reducer, we will use the useReducer hook. We will call useReducer with the reducer function and an initial state. We will pass the results of useReducer as a value to the context.
Next, let's provide this context to the root App component. 👇
We will use the StateProvider function we have just created earlier like this:
// App.js
import React
from 'react';
import './App.css';
import { StateProvider } from './contexts'
import reducer, { initialState } from './reducers'
import WithContext from './components/WithContext';
function App() {
return (
<StateProvider initialState={initialState} reducer={reducer}>
<div className="App">
<h3>Posts List coming from reducer</h3>
<WithContext></WithContext>
</div>
</StateProvider>
);
}
export default App;
Now that we have our global state initialized, let's have a look at the reducers...
Here is a simple version of how the posts reducer may look like:
// reducers/posts
import { SET_POSTS } from '../actions/types';
export const postsReducer = (state = postsInitialState, action) => {
switch (action.type) {
case SET_POSTS:
return {
...state,
posts: action.payload
};
default:
return state;
}
}
export const postsInitialState = {
posts: []
}
export default postsReducer
Keep in mind: This is an example. For more complex scenarios you may need things like loading and error states. 😇
Now let's create a global reducer where all other reducers are glued together....
// reducers/index
import postsReducer , { postsInitialState } from './posts'
export const initialState = {
postsState: postsInitialState
}
const mainReducer = ({ posts }, action) => ({
postsState: postsReducer(posts, action)
})
export default mainReducer
So far so good, we have our reducers in place! Next step, we will need actions to describe to update our state. 💪🏼
Again a very simple action could look something like this:
// actions/index
import { SET_POSTS } from './types';
export function setPosts(data) {
return {
type: SET_POSTS,
payload: data
};
}
And we can define our action types in a separate file like this:
// actions/types
export const SET_POSTS = 'SET_POSTS';
Now you have all your building blocks in place and your global state is ready! 🎉
Let's see how we can read and update the global state.
Remember that custom hook we created earlier? useStateFromContext? Now we can use it! 😁
Here is an example of how we would read the blog posts from the global state and pass it to a child component...
// components/PostListFromContext
import React from 'react';
import PostsList from './PostsList';
import { useStateFromContext } from '../contexts'
function PostListFromContext() {
const [ { postsState }] = useStateFromContext()
return <PostsList data={postsState.posts} />;
}
export default PostListFromContext;
All good, but what about adding more blog posts?
You dispatch an action...👇
Our custom hook useStateFromContext returns the dispatch function as a second value!
The assumption: we will be fetching some data from an API. Once we get the data, we would like to update the global state with the API results.
Here is how this may look like using axios.
// components/WithContext
import React from 'react';
import Button from '@material-ui/core/Button';
import PostListFromContext from './PostListFromContext';
import { useStateFromContext } from '../contexts'
import { setPosts } from '../actions'
import axios from 'axios';
const POSTS_SERVICE_URL = 'https://jsonplaceholder.typicode.com/posts';
function WithContext() {
const [ _, dispatch] = useStateFromContext()
const fetchPosts = async () => {
try {
const response = await axios.get(POSTS_SERVICE_URL);
const posts = response.data
dispatch(setPosts(posts))
} catch (e) {
console.log(e);
}
}
return (
<div>
<Button variant="contained" onClick={fetchPosts}>Fetch posts</Button>
<PostListFromContext />
</div>
);
}
export default WithContext;
Once the user clicks the button, an API call happens and the global state gets updated with the new blog posts.
Now you have an easy way to manage a global state without relying on any 3rd party libraries. 🙌
This state management pattern is great for mid-sizes apps. Depending on your use-case, it might be a better choice. In comparison with introducing Redux with its overhead.
But.... what about middlewares? 🤔
At the end of the day, middlewares are just functions that are called with every action. In case you would like to create your own custom middleware. You can simply add your middleware functions inside the StateProvider
// contexts/index.js
export const StateProvider = ({ reducer, initialState, children }) => {
/*
Add here your middleware logic....
*/
return (
<StateContext.Provider value={useReducer(reducer, initialState)}> {/* useReducer returns the state and a dispatch function to update state */}
{children}
</StateContext.Provider>
)
};
Of course, this pattern has its limitations.
Two main problems that come to mind are:
You don't get the same great developer experience When debugging your global state. In comparison to Redux.
Also, more complex middlewares might be problematic to use.
With that said, this pattern is a simpler choice over Redux.
As always consider your own specific use-case first before making any decisions! 😁
Enjoyed the article? Share the summary thread on twitter.