Simplifying Global State with React Hooks
Build a minimalist global state management solution with useContext and useReducer
Intro
In this tutorial we will be building a minimalist global state management solution using React hooks.
We’ll create a store to hold the data as the source of truth. We’ll connect that store to a Context.Provider
at the root of our application to feed the data from our store, and we’ll consume that data in our components with the useContext
hook.
To update the store, we’ll define a reducer function and utilize the useReducer
hook. The end result will be a highly reusable component that can be dropped anywhere in our application, and without being passed any props, has access to our global state.
Background
I’m not an expert in state management, but I think there are basically two options for sharing state in React. You can pass props downward through the tree from parent to child, or you can use React’s Context. A little while ago, this almost always meant utilizing a state management library such as Redux, MobX, unstated, or Apollo Link State.
Recently, with v16.3.0, React released a new first-class Context API. State management libraries like Redux were already implemented on the previous API, but it was undocumented and experimental. With the new official Context API, the React team made it very easy to share global state.
Even more recently, with v16.7.0-alpha, React introduced hooks, which are getting a ton of attention and for good reason. We’re going to combine these two great features in our project today.
The Project
So let’s say we have this simple Counter on our site. Users love clicking the plus or minus button and watching the number go up or down. A highly requested feature is that the counter persists its current count everywhere on the site.
Let’s say our app structure looks like this:
root
|--public
| |-index.html
|--src
| |--pages
| | |-HomePage.js
| | |-AnotherPage.js
| |-index.js
| |-Counter.js
Create a store
Our first step is to create a store to house the data. Let’s create a store.js
in our src
directory. In it, let’s set our initial state.
export const initialState = { count: 0 }
Next, we’ll define our reducer. If you haven’t used them before, reducers are more or less giant switch statements. They should always be pure — they should never alter the input data or depend on any external data. This ensures that it always returns the same value from the same input.
export const reducer = (state, action) => {
switch (action.type) {
case "reset":
return initialState
case "increment":
return { count: state.count + 1 }
case "decrement":
return { count: state.count - 1 }
default:
return state
}
Our reducer accepts two arguments, the current state and an incoming action. Convention is for the action to be an object that contains a type
property. The reducer switches on that type to determine how to transform state. We are handling three types of actions above: increments, decrements and resets.
The last thing we’ll do in our store is create a context object.
export const Context = React.createContext()
Provide Global State
Now let’s go to the root of our application. We need to feed the data from our store to the application so that we can hook into it from our components. To do that, we’ll wrap our application in a Context.Provider
import React, { useReducer } from 'react'
import {
Context,
initialState,
reducer
} from './stores/counterStore'function App() {
const [store, dispatch] = useReducer(reducer, initialState);
return (
<Context.Provider value={{ store, dispatch }}>
{/* <Main.js /> or whatever */}
</Context.Provider>
)
}
useReducer
takes two arguments: the reducer we defined in our store and the store’s initial state. It returns to us an array containing the current state from the store and a dispatch method that we’ll use to dispatch actions to the store.
Consume State and Dispatch Actions
Finally, we’re ready to consume the state that is now globally available. Let’s take a look at our existing Counter
component.
class Counter {
state = {
count: 0
}; increment = () =>
this.setState(prevState => ({ count: prevState.count + 1 })); decrement = () =>
this.setState(prevState => ({ count: prevState.count - 1 })); reset = () => this.setState({ count: 0 }); render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={this.increment}>+</button>
<button onClick={this.decrement}>-</button>
<button onClick={this.reset}>Reset</button>
</div>
);
}
}
We’ll utilizeuseContext
to hook into the context provider wrapping our application. This will give us access to the current count as well as the dispatch
method we’ll use to dispatch actions to our reducer. All of the methods we have on the class now can be replaced with dispatch
.
Here’s our Counter after the changes:
const Counter = () => {
const { store, dispatch } = useContext(Context)
return (
<div>
<p>You clicked {store.count} times</p>
<button onClick={
() => dispatch({ type: "increment" })
}>+</button>
<button onClick={
() => dispatch({ type: "decrement" })
}>-</button>
<button onClick={
() => dispatch({ type: "reset" })
}>Reset</button>
</div>
);
};
And we’re done! We can now drop this component anywhere on any page of our site and it will have access to the global state of our counter. We don’t need to enhance that page with an HoC or map state to props, it’s just there.
We were able to refactor our Counter into a functional component and decouple the logic from the presentation. Now we have a clearer separation of concerns and we’ve made our app more extensible. Perhaps in the future, we’ll implement a feature that punishes users for bad behavior by decrementing their hard-earned count by 5 when they click on the wrong button. All we’d have to do is add a new action type to our reducer and hook into the store in some other component — two or three lines of code.
Conclusion
I hope this was helpful and easy to follow. I’m having fun experimenting with hooks — feedback and corrections are always welcome!
Here’s a codesandbox with the final working code. Thanks for reading!