Opinionated React: Use Context for Shared State

farazamiruddin

faraz ahmad

Posted on June 9, 2020

Opinionated React: Use Context for Shared State

Intro

I’ve been working with React for over four years. During this time, I’ve formed some opinions on how I think applications should be. This is part 5 in the series of such opinionated pieces.

My Pattern for React Context

My buddy Nader asked how I use React Context in my apps. I promised I'd write about it, so here we are.

Why

There are some instances in your application state is needed by multiple components. I will use context if this shared state requires a lot of prop drilling. In the past, Redux was a popular solution to avoid prop drilling. However, I don't believe Redux is needed anymore. React's context api works great for this.

Use Cases - Important!

  • You should use React context for global state. That being said, there aren't that many pieces of global state. Some good examples of global state are the current user, the current language setting, or a map of feature flags.

  • You don't need to use context only for global state. Context can be applied to a specific sub-tree of your application.

  • It's common to have multiple sub-tree specific contexts.

Inspiration

I originally learned this pattern from Kent C. Dodd's excellent post How to use React Context effectively, I recommend reading this. Tanner Linsley also covers similar concepts in his talk Custom Hooks in React: The Ultimate UI Abstraction Layer.

Example

The end goal is to have an api that looks like this.

export const App = ({ userId }) => {
  return (
    <UserProvider id={userId}>
      <Dashboard />
    </UserProvider>
  );
};

const Dashboard = () => {
  const { isLoading, user } = useUserState();
  if (isLoading) {
    return <div>Loading...</div>;
  }
  return (
    <div>
      <h1>Dashboard</h1>
      <div>Hello {user.displayName}!</div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

Let's work backwards to get to this solution.

First, let's start with defining the state of our context, as well as the two contexts we will be creating.

interface UserState {
  user?: User;
  isLoading: boolean;
}

const UserStateContext = React.createContext<UserState | undefined>(undefined);
const UserDispatchContext = React.createContext<UserDispatch | undefined>(
  undefined
);
Enter fullscreen mode Exit fullscreen mode

We are creating two separate contexts because not all components will need access to both state and dispatch. This way, a component can use only the context it requires. The added benefit is that if a component is only using dispatch, it will not re-render on state change because it is not using that context.

For state management within the context, we're going to use useReducer.

// omitted rest of the file

enum UserActionTypes {
  LOADING = "loading",
  SUCCESS = "success"
}
type UserAction =
  | { type: UserActionTypes.LOADING }
  | { type: UserActionTypes.SUCCESS; payload: User };
type UserDispatch = (action: UserAction) => void;

function userReducer(state: UserState, action: UserAction): UserState {
  switch (action.type) {
    case UserActionTypes.LOADING: {
      return { isLoading: true };
    }
    case UserActionTypes.SUCCESS: {
      return { isLoading: false, user: action.payload };
    }
    default: {
      throw new Error("Invalid action type");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I often write contexts like this. At app startup, we'd like to fetch information about the currently logged in user, and make that data available globally.

The user you want to fetch might be determined by an id, and since Provider components can accept props, we can simply pass in an id so when our Context mounts, we fetch the user.

Here's what the provider component looks like.

export const UserProvider: React.FC<{ id: string }> = ({ id, children }) => {
  const [state, dispatch] = React.useReducer(userReducer, { isLoading: true });

  React.useEffect(() => {
    const handleGetUser = async id => {
      dispatch({ type: UserActionTypes.LOADING });
      const user = await getUserById(id);
      dispatch({ type: UserActionTypes.SUCCESS, payload: user });
      return;
    };
    handleGetUser(id);
    return;
  }, [id]);

  return (
    <UserStateContext.Provider value={state}>
      <UserDispatchContext.Provider value={dispatch}>
        {children}
      </UserDispatchContext.Provider>
    </UserStateContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

In most of my applications, I'm using hooks, so we will define the hooks here.

export const useUserState = () => {
  const userStateContext = React.useContext(UserStateContext);
  if (userStateContext === undefined) {
    throw new Error("useUserState must be used within a UserProvider");
  }
  return userStateContext;
};

export const useUserDispatch = () => {
  const userDispatchContext = React.useContext(UserDispatchContext);
  if (userDispatchContext === undefined) {
    throw new Error("useUserDispatch must be used within a UserProvider");
  }
  return userDispatchContext;
};
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Here's everything together:

import * as React from "react";
import { getUserById } from "../services/user-service";
import { User } from "../types/user";

interface UserState {
  user?: User;
  isLoading: boolean;
}
enum UserActionTypes {
  LOADING = "loading",
  SUCCESS = "success"
}
type UserAction =
  | { type: UserActionTypes.LOADING }
  | { type: UserActionTypes.SUCCESS; payload: User };
type UserDispatch = (action: UserAction) => void;

const UserStateContext = React.createContext<UserState | undefined>(undefined);
const UserDispatchContext = React.createContext<UserDispatch | undefined>(
  undefined
);

function userReducer(state: UserState, action: UserAction): UserState {
  switch (action.type) {
    case UserActionTypes.LOADING: {
      return { isLoading: true };
    }
    case UserActionTypes.SUCCESS: {
      return { isLoading: false, user: action.payload };
    }
    default: {
      throw new Error("Invalid action type");
    }
  }
}

export const UserProvider: React.FC<{ id: string }> = ({ id, children }) => {
  const [state, dispatch] = React.useReducer(userReducer, { isLoading: true });

  React.useEffect(() => {
    const handleGetUser = async id => {
      dispatch({ type: UserActionTypes.LOADING });
      const user = await getUserById(id);
      dispatch({ type: UserActionTypes.SUCCESS, payload: user });
      return;
    };
    handleGetUser(id);
    return;
  }, [id]);

  return (
    <UserStateContext.Provider value={state}>
      <UserDispatchContext.Provider value={dispatch}>
        {children}
      </UserDispatchContext.Provider>
    </UserStateContext.Provider>
  );
};

export const useUserState = () => {
  const userStateContext = React.useContext(UserStateContext);
  if (userStateContext === undefined) {
    throw new Error("useUserState must be used within a UserProvider");
  }
  return userStateContext;
};

export const useUserDispatch = () => {
  const userDispatchContext = React.useContext(UserDispatchContext);
  if (userDispatchContext === undefined) {
    throw new Error("useUserDispatch must be used within a UserProvider");
  }
  return userDispatchContext;
};

Enter fullscreen mode Exit fullscreen mode

This is the fifth post in a series of posts I will be doing. If you enjoyed this, please give me some hearts and leave a comment below. What else would you

As always, I'm open to recommendations.

Thanks for reading.

💖 💪 🙅 🚩
farazamiruddin
faraz ahmad

Posted on June 9, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related