We'll pretend like I wrote an interesting preface to this article so we can skip to the good stuff. In short, we'll use useReducer
and useContext
to create a custom React hook that provides access to a global store, similar to redux.
I'm in no way suggesting this solution has feature-parity with redux, because I'm sure it doesn't. By "redux-like", I mean that you'll be updating the store by dispatching actions, which resolve to mutate and return a fresh copy of the mutated state. If you've never used redux, pretend like you didn't read this.
Before we start, I created a Code Sandbox with the full implementation, if you'd rather just play around with the code.
The Hook
Let's start by creating the Context
that will contain our state
object and dispatch
function. We'll also the useStore
function that will act as our hook.
// store/useStore.js
import React, { createContext, useReducer, useContext } from "react";
// we'll leave this empty for now
const initialState = {}
const StoreContext = createContext(initialState);
// useStore will be used in React components to fetch and mutate state
export const useStore = store => {
const { state, dispatch } = useContext(StoreContext);
return { state, dispatch };
};
Since everything is stored in React Context, we'll need to create a Provider that gives us the state
object and the dispatch
function. The Provider is where we'll use useReducer
.
// store/useStore.js
...
const StoreContext = createContext(initialState);
export const StoreProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
};
...
We use useReducer
to get state
, and dispatch
, because that's what useReducer
does. We pass state
and dispatch
to the provider. We can wrap any React component we want with <Provider/>
, and that component can then use useStore
to interact with the state.
We haven't created the reducer
yet, that's the next step.
// store/useStore.js
...
const StoreContext = createContext(initialState);
// this will act as a map of actions that will trigger state mutations
const Actions = {};
// the reducer is called whenever a dispatch action is made.
// the action.type is a string which maps to a function in Actions.
// We apply the update to existing state, and return a new copy of state.
const reducer = (state, action) => {
const act = Actions[action.type];
const update = act(state);
return { ...state, ...update };
};
...
I'm a big fan of separating actions/state into logical groups. For example, you might want to keep track of your counter's state (this is the classic Counter example). At the same time, you might also want to keep track of current User state, like if a user is logged in, and what their preferences are. In some component, you might need access to both of these different "states", so keeping them in one global store makes sense. But we can separate out the actions into logical groups, like a userActions
and a countActions
, which will make managing them much easier.
Lets create a countActions.js
and userActions.js
file in the store
directory.
// store/countActions.js
export const countInitialState = {
count: 0
};
export const countActions = {
increment: state => ({ count: state.count + 1 }),
decrement: state => ({ count: state.count - 1 })
};
// store/userActions.js
export const userInitialState = {
user: {
loggedIn: false
}
};
export const userActions = {
login: state => {
return { user: { loggedIn: true } };
},
logout: state => {
return { user: { loggedIn: false } };
}
};
In both of these files, we export initialState
because we want to combine these in useStore.js
into one initialState
object.
We also export an Actions object that provides functions for mutating state. Notice that we don't return a new copy of state, because we do that in the actual reducer
, in useStore.js
.
Lets import these into useStore.js
to get the complete picture.
// store/useStore.js
import React, { createContext, useReducer, useContext } from "react";
import { countInitialState, countActions } from "./countActions";
import { userInitialState, userActions } from "./userActions";
// combine initial states
const initialState = {
...countInitialState,
...userInitialState
};
const StoreContext = createContext(initialState);
// combine actions
const Actions = {
...userActions,
...countActions
};
const reducer = (state, action) => {
const act = Actions[action.type];
const update = act(state);
return { ...state, ...update };
};
export const StoreProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
};
export const useStore = store => {
const { state, dispatch } = useContext(StoreContext);
return { state, dispatch };
};
We did it! Take a victory lap, then come back and we'll take a look at how to use this in a component.
Welcome back. I hope your lap was victorious. Let's see useStore
in action.
First, we can wrap our initial App
component in the <StoreProvider/>
.
// App.js
import React from "react";
import ReactDOM from "react-dom";
import { StoreProvider } from "./store/useStore";
import App from "./App";
function Main() {
return (
<StoreProvider>
<App />
</StoreProvider>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<Main />, rootElement);
We're wrapping App
in StoreProvider
so an child component will have access to the value in the provider, which is both state
and dispatch
.
Now, lets say we had an AppHeader
component that has a login/logout button.
// AppHeader.jsx
import React, {useCallback} from "react";
import { useStore } from "./store/useStore";
const AppHeader = props => {
const { state, dispatch } = useStore();
const login = useCallback(() => dispatch({ type: "login" }), [dispatch]);
const logout = useCallback(() => dispatch({ type: "logout" }), [dispatch]);
const handleClick = () => {
loggedIn ? logout() : login();
}
return (
<div>
<button onClick={handleClick}> {loggedIn ? "Logout" : "Login"}</button>
<span>{state.user.loggedIn ? "logged in" : "logged out"}</span>
<span>Counter: {state.count}</span>
</div>
);
};
export default AppHeader;
Here's a Code Sandbox will the full implementation!
Top comments (6)
Hey I have a question -- I really like this, and implemented something similar in my own app, but I've noticed that because I use the 'useStore' call in a bunch of different components, it's getting called dozens of times on load. Do you think this will be a significant issue at scale or is there a way to implement it so doesn't need to get called over and over to fetch values?
Hi Brendan! In production at work, we have actually moved dispatch into its own context and have a
useDispatch
anduseStore
hook. Usually, components needed to dispatch actions don't generally need to use the state itself. This will probably cut down on a lot of the re-renders you're seeing.Another option for possibly reducing the amount of calls would be to create a "hydrate store" action that does all the initial loading of state you need in one action. I'm not sure how feasible this is in your project, but its something we're doing as well.
Ramsay, loved your Article. I actually was able to follow it and implement it on something that I have been working on. Wondering you how you would have created the
useDispatch
and the "hydrate store" action?Thanks for the reply! This definitely gives me something to chew on
Hi, What is the best way to modify this to pass data into the store with dispatch? 😊
Thanks Brendon. I converted this pattern to use Typescript. If anyone is interested you can see it on a Gist here gist.github.com/TimeBandit/e71f42f...
Some comments may only be visible to logged-in visitors. Sign in to view all comments.