Manage your state with Context and useReducer

A common React challenge

Choosing a state management solution for a React application can quickly become overwhelming. The right choice depends largely on the size of your app and the complexity of the data you’re working with. While there are many popular libraries, such as MobX, Redux, and Zustand, not every project needs an external dependency.

In this article, we’ll explore one of the simplest and most effective patterns available in React today: combining the Context API with the useReducer hook. This approach is built entirely with React’s core features and works especially well for small to medium-sized applications.

At a high level, React applications usually deal with two kinds of state:

  • Local state, which lives inside a single component

  • Global state, which needs to be shared across multiple components in the component tree

Thanks to the introduction of the Context API and Hooks, managing global state no longer requires a third-party library. When state logic becomes more complex, useReducer provides a structured and predictable way to handle state transitions—much more suitable than useState for these scenarios.


Every library's approach tries to tackle the same problem in its own way. With the adoption of React Hooks API, one such option is to use a combination of useReducer hook and the Context API. In this post, we'll take a look at how to manage the global state in a React app using both of them.

There are two types of states to deal with in React apps. The first type is the local state that is used only within a React component. The second type is the global state that can be shared among multiple components within a React application's tree.

With the release of Context API as well as Hooks API, implementing a global state is possible without installing any additional state management library. The useReducer hook is a great way to manage complex state objects and state transitions. You may have seen or used useState to manage simple or local state in React apps.


How useReducer works

The useReducer hook is an alternative to useState. Its main advantage is that it scales better when dealing with complex state objects or multiple related state transitions.

Instead of directly setting state, useReducer relies on a reducer function that describes how the state should change in response to specific actions. The hook returns two values:

  • The current state

  • A dispatch function used to trigger state updates

As stated in the official React documentation:

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one.


Creating the initial state

Let’s start with a simple example. Our state will consist of a single count property that we can increment or decrement.

const initialState = { count: 0 };

Defining action identifiers

Next, we define a set of action types. These identifiers allow the reducer to determine how the state should be updated.

const ACTIONS = {
  INCREMENT: 'INCREMENT',
  DECREMENT: 'DECREMENT',
};

Create the reducer function

The reducer function takes the current state and an action as arguments. Based on the action type, it returns a new state object.

In this example, we’re simply incrementing or decrementing the counter.

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

Using a payload with actions


const []

// Dispatching an action with a payload
 dispatch({
   type: ACTIONS.INCREMENT
   payload: { amount : 15, message: "Yay!" }
 });

const reducer = (state, action) => {
 // destructure the payload object
 const { amount, message } = action.payload
  switch (action.type) {
    case ACTIONS.INCREMENT:
      return {
        ...state,
        count: state.count + amount,
        message: message
      };
    //  same as before, implement your logic here
    default:
      return state;
  }
};


Creating the Context

Now let’s create the context that will hold our global store.

const AppContext = createContext();

Create the store Provider

The StoreProvider component is responsible for initializing the state and making it available to the rest of the application.

Inside this provider, we use useReducer to manage the state and useMemo to avoid unnecessary re-renders.

const StoreProvider = ({ children }) => {
  if (!children) throw new Error('StateProvider! no children given');

  // Access to state and dispatch function
  const [state, dispatch] = useReducer(reducer, initialState);
  const store = useMemo(() => ({ state, dispatch }), [state, dispatch]);

  return <AppContext.Provider value={store}>{children}</AppContext.Provider>;
};

Creating a custom hook to access the store

To make consuming the context easier and safer, we can create a custom hook. This hook ensures that the context is only used within the provider.

const useStore = () => {
  const context = useContext(AppContext);
  if (!context) throw new Error('useStore must be used within StateProvider!');
  return context;
};

Exporting what you need

export { useStore, StoreProvider };

Demo

You can find a working example on CodeSandbox here