Create Redux-like state management with React βš›

Introduction

In this article, I would like to show you how to create a Redux-like approach to state management using only React built-in utilities.

Before we begin, I would like to note that this article is for educational purposes only and if you're about to start working on a commercial application that contains a lot of complex business logic, it would be better to use Redux or some other state management library e.g. MobX, just to avoid additional overhead and refactoring in the future.

Code

To keep it as simple as possible, let's create some basic counter app that has two options - incrementing and decrementing counter value. We will start from declaring initial state and types for our actions.

type State = { counter: number };

type Action = { type: "INCREMENT" } | { type: "DECREMENT" };

const initialState: State = { counter: 0 };

Now we need to create reducer - a simple function that is responsible for modifying and returning updated state based on action type.

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "INCREMENT":
      return {
        ...state,
        counter: state.counter + 1
      };
    case "DECREMENT":
      return {
        ...state,
        counter: state.counter - 1
      };
    default:
      return state;
  }
};

Once we have our reducer ready, we can pass it to the useReducer hook that returns current state paired with dispatch method that's responsible for executing actions, but in order to use it all across our application we need some place where we can store it. For that, we will use React context.

import {
  createContext,
  Dispatch,
  ReactNode,
  useContext,
  useReducer
} from "react";

const StoreContext = createContext<[State, Dispatch<Action>]>([
  initialState,
  () => {} // initial value for `dispatch`
]);

export const StoreProvider = ({ children }: { children: ReactNode }) => (
  <StoreContext.Provider value={useReducer(reducer, initialState)}>
    {children}
  </StoreContext.Provider>
);

export const useStore = () => useContext(StoreContext);

Take a look at the useStore hook we created using useContext. This hook will allow us to access state and dispatch in each child component of StoreProvider.

In this example, I will use StoreProvider in render method which will cause our state to be accessible globally, but I would like to note that you should keep your state as close to where it's needed as possible, since updates in context will trigger re-render in each of the providers' child components which might lead to performance issues once your application grows bigger.

import { render } from "react-dom";
import App from "./App";
import { StoreProvider } from "./store";

const rootElement = document.getElementById("root");

render(
  <StoreProvider>
    <App />
  </StoreProvider>,
  rootElement
);

Now we can create a UI for our counter app and see useStore hook in action.

export default function App() {
  const [state, dispatch] = useStore();

  return (
    <div className="container">
      <button onClick={() => dispatch({ type: "INCREMENT" })}>Increment</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>Decrement</button>
      <p>Counter: {state.counter}</p>
    </div>
  );
}

And that's it!

Demo

If you want to take a closer look at code and see how this application works live, check out this sandbox πŸ‘€

Thanks for reading! πŸ‘‹

21