Hi, I am Sanjeet Tiwari...
Let's talk about

Back to Blog

My Context Module Pattern

Just imagine a file, where all of your state, all the possible actions upon it, and the utilities to retrieve the state resides.

In React terminology, just imagine a React Hook which returns the live state, and all the actions alongside it, which can update the state and can be used anywhere in the application.

Sounds intriguing, right ?

Let’s head straight into the action! Oh and btw, I am a sucker for TypeScript, so the below coding will be in TypeScript.

Let’s try and create a context module for our count feature wherein we should be able to increment, decrement, reset and set our counter to a specific value.

And also, a special note, this file is going to be a .tsx file as we are going to export our CountContextProvider as well.

Types

One of the most tedious jobs while using TypeScript is preparing or defining all the types that will be needed in the code. Although, once the setup is done, coding feels a whole lot safer and clean.

Let’s start with defining what our count state will look like!

type CountStateType = {
  count: number;
};

One more thing to note here - I personally prefer to use type instead of interface. I have my reasons.

Now, it’s time to define how our actions will look like!

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" }
  | { type: "set"; payload: number };

Next, we need a way to dispatch these actions, right ?

A dispatch is nothing but a function carrying an action that needs to be executed on state.

type DispatchType = (action: Action) => void;

Now we have a state and a dispatch! Everything we need to define our context!

type ContextType = {
  state: CountStateType;
  dispatch: DispatchType;
};

Any more types left ? Hmm, let me see!

Oh wait! We need one more type to define the props for our context provider.

type CountContextProviderProps = {
  children: React.ReactNode;
};

Context

Now comes the part where we create a context for our feature.

const CountContext = createContext<ContextType | undefined>(undefined);

You must be thinking why did I put that extra undefined in there ? Well, context can be undefined if useContext hook, which can retrieve the tools of our feature, is used outside CountContextProvider which is our context provider.

Make sure to have all necessary imports ready in your code! Very important!

Reducer

We will be using useReducer hook later in the code, which requires us to create a reducer for our count feature which computes the new state on the basis of existing state and the action that is performed on it.

function countReducer(state: CountStateType, action: Action): CountStateType {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: 0 };
    case "set":
      return { count: action.payload };
  }
}

Provider

It’s finally time to create the Provider for our count feature.

We are going to use useReducer hook here as I had promised earlier, and we will need an initial state which we will pass to the useReducer hook.

A Provider also needs a value which will be available to all the consumers via the useContext hook.

Here’s what we are going to do - wrap the state and dispatch that we will get from useReducer into an object! Wild, right ?

So, this is what our Provider will look like -

const initialState: CountStateType = {
  count: 0,
};

export default function CountProvider({ children }: CountContextProviderProps) {
  const [state, dispatch] = useReducer(countReducer, initialState);
  return (
    <CountContext.Provider value={{ state, dispatch }}>
      {children}
    </CountContext.Provider>
  );
}

The Ultimate Hook

This is one of the major differentiating factors between my Context Module pattern and Kent’s Context Module pattern. I have provisioned various action functions which can be retrieved from the same hook which also delivers the live state to the consumers.

export function useCount() {
  const context = useContext(CountContext);

  if (context === undefined)
    throw new Error("Please use the useCount hook inside CountProvider");
  const { state, dispatch } = context;

  function incrementCounter() {
    dispatch({ type: "increment" });
  }

  function decrementCounter() {
    dispatch({ type: "decrement" });
  }

  function resetCounter() {
    dispatch({ type: "reset" });
  }

  function setCounter(newValue: number) {
    dispatch({ type: "set", payload: newValue });
  }

  return {
    state,
    incrementCounter,
    decrementCounter,
    setCounter,
    resetCounter,
  };
}

Notice the use of undefined here which we talked about earlier in the blog. This can be highly useful as many developers usually do forget that they need to wrap the component tree with a Provider first.

Usage

In order to use the state or all it’s actions in a component, all we need to do -

const { state, incrementCounter } = useCount();
.
.
.
return (
  <div>
    <div>{state.count}</div>
    <button onClick={incrementCounter}>Increment</button>
  </div>
)

Doesn’t it make more sense this way ? And this can be used anywhere in the application as long as it is inside the Provider.

All Stitched Together

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

type CountStateType = {
  count: number;
};

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" }
  | { type: "set"; payload: number };

type DispatchType = (action: Action) => void;

type ContextType = {
  state: CountStateType;
  dispatch: DispatchType;
};

type CountContextProviderProps = {
  children: React.ReactNode;
};

const CountContext = createContext<ContextType | undefined>(undefined);

const initialState: CountStateType = {
  count: 0,
};

function countReducer(state: CountStateType, action: Action): CountStateType {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: 0 };
    case "set":
      return { count: action.payload };
  }
}

export default function CountProvider({ children }: CountContextProviderProps) {
  const [state, dispatch] = useReducer(countReducer, initialState);
  return (
    <CountContext.Provider value={{ state, dispatch }}>
      {children}
    </CountContext.Provider>
  );
}

export function useCount() {
  const context = useContext(CountContext);

  if (context === undefined)
    throw new Error("Please use the useCount hook inside CountProvider");
  const { state, dispatch } = context;

  function incrementCounter() {
    dispatch({ type: "increment" });
  }

  function decrementCounter() {
    dispatch({ type: "decrement" });
  }

  function resetCounter() {
    dispatch({ type: "reset" });
  }

  function setCounter(newValue: number) {
    dispatch({ type: "set", payload: newValue });
  }

  return {
    state,
    incrementCounter,
    decrementCounter,
    setCounter,
    resetCounter,
  };
}

At the end, all I want to say is that this pattern is one of the most clean, concise and readable state management patterns out there, and I’ve been in this game for 7 years now!

Do let me know if you liked it! Thanks!

Sources

Sanjeet's LinkedIn

How to use React Context effectively

Kent C. Dodds

Last updated on 18-11-2024