DEV Community

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 };
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
...
Enter fullscreen mode Exit fullscreen mode

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 };
};
...
Enter fullscreen mode Exit fullscreen mode

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 })
};
Enter fullscreen mode Exit fullscreen mode
// store/userActions.js

export const userInitialState = {
  user: {
    loggedIn: false
  }
};

export const userActions = {
  login: state => {
    return { user: { loggedIn: true } };
  },
  logout: state => {
    return { user: { loggedIn: false } };
  }
};
Enter fullscreen mode Exit fullscreen mode

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 };
};

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Here's a Code Sandbox will the full implementation!

Discussion (6)

pic
Editor guide
Collapse
visarts profile image
Brendan B

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?

Collapse
ramsay profile image
Ramsay Author

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.

Collapse
juwdohr profile image
Juwdohr

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?

Collapse
visarts profile image
Brendan B

Thanks for the reply! This definitely gives me something to chew on

Collapse
timebandit profile image
Imran Nazir

Hi, What is the best way to modify this to pass data into the store with dispatch? 😊

Collapse
timebandit profile image
Imran Nazir

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...