loading...

Build a Redux-like Global Store Using React Hooks

ramsay profile image Ramsay ・4 min read

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!

Posted on by:

ramsay profile

Ramsay

@ramsay

At work, I'm a senior web developer. At home, I'm a senior dad joke developer.

Discussion

markdown guide
 

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 and useStore 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