The Elegant Context Pattern in React

The Elegant Context Pattern in React

  • 330

The Elegant Context Pattern in React.Composing the React Context Provider and useContext Hook so they can be used and re-used comfortably Context in React is used to share

Introduction

Context in React is used to share data that is global to a component tree such as an authenticated user or preferred theme without passing that data as props. Here we won’t be discussing the basics of React Context API but instead we will see how we can use it elegantly.

App we will develop

This is image title

function App(props) {
  const [number, setNumber] = useState(0);
  const changeNumberHandler = () => {
  setNumber(c => c + 1)
  }
  return (
   <div className="App">
    The Main App Component {number}
    <UserProvider> {/* Our Custom Provider */}
      <Comp1 />
    </UserProvider>
    <button onClick={changeNumberHandler}>
      Changing Main Component Number
    </button>
   </div>
  );
 }

// comp1 renders
<Comp2 />
// ... which renders ...
<Comp3 />
<Comp4 />

Here we will be using [https://jsonplaceholder.typicode.com/users](https://jsonplaceholder.typicode.com/users)/{userId} to fetch the user of userId. We will use Math.random() to generate a random number from 1 to 10 which we will use as userId. After fetching the result, we will provide that result to Comp3 and Comp4. Along with this, we will also provide a function that will change userId and fetch the result for that new userId.

A Flick Through Context

React.createContext

Creates a new context object that is used to pass down the value to a component tree without using props.

const MyContext = React.createContext(defaultValue);

Context.Provider

Provider allows the consuming React component to subscribe to the context changes.

<MyContext.Provider value={someValue}>

All the consumers that are descendants of Provider will re-render whenever the value prop changes.

useContext Hook

It can be used in functional components which accepts a context object and returns the current context value.

const value = useContext(MyContext);

Custom Provider and Hook

We will create one custom provider (UserProvider) which will be used to provide the value down to every descendant. To consume that value, we will create one custom Hook (useUser).

import React, {
  useState,
  useEffect,
  useContext,
} from 'react'
import axios from 'axios'

const UserContext = React.createContext(undefined);

const UserProvider = ({
  children
}) => {
  const [user, setUser] = useState(null);
  const [random, setRandom] = useState(1);

  useEffect(() => {
    const url = `https://jsonplaceholder.typicode.com/users/${random}`;
    axios.get(url).then(res => {
      setUser(res.data);
    }).catch(err => {
      console.error(err)
    });
  }, [random]);

  const changeUser = () => {
    const randomNumber = Math.floor(Math.random() * 10 + 1);
    setRandom(randomNumber);
  }

  const data = [
    user,
    changeUser
  ]

  return (
    <UserContext.Provider value={data}>
      {children}
    </UserContext.Provider>
  )
}

const useUser = () => {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error('useUser can only be used inside UserProvider');
  }
  return context;
}

export {
  UserProvider,
  useUser
}

user1.js

Here we have given undefined as the default value to the context. This default value, in general, is used whenever we try to use the value of context objects in components that are not the decendants of the provider of this context object.

We are giving an array to the value prop of UserContext.Provider. This array has the value of user state and a function changeUser that is calculating a random number from 1 to 10 and storing it in a state callled random.

We are making an Ajax call to our endpoint in the useEffect Hook, which has only random state in its dependency array. That means this effect will only execute when our random state changes.

In the return statement, we are writing the main context provider logic, which will pass down the values to all consumers which are its descendants.

<UserContext.Provider value={data}>
  {children}
</UserContext.Provider>

After our custom provider, we have created our custom Hook. In this Hook, first we are using a useContext Hook to get the value of context object. After that, to prevent accidental usage of our custom Hook in any non-descendant component, we are checking whether the value obtained using useContext is equal to our defaultValue given during the creation of our context object. In our case, this default value is undefined. If the value is undefined, then we can say that our custom Hook is used in any non-descendant component.

//undefined is given as default value
const UserContext = React.createContext(undefined);

Then, from our custom Hook, we return the obtained context value.

Gotchas

We know that all descendant components of Provider will re-render as the value prop changes and also they cannot bail out of the updating even though they have used PureComponent, shouldComponentUpdate or React.memo.

Also, there could be some unintentional renders in consumers when a provider’s parent re-renders.

Let’s use React.memo in Comp1 so that no unintentional renders happen in Comp3 and its children due to changes in App component.

import React from 'react';
import Comp2 from './comp2';

const Comp1 = () => (
  <div className="comp1">
    Comp1
    <Comp2 />
  </div>
)

export default React.memo(Comp1);

Comp1.js

import React, {useState} from 'react';

import Comp1 from './comp1';
import { UserProvider } from './user'
import './App.css';

function App() {
  const [number, setNumber] = useState(0);

  const changeNumberHandler = () => {
    setNumber(c => c + 1)
  }

  return (
    <div className="App">
      The Main App Component {number}
      <UserProvider> {/* Our Custom Provider */}
        <Comp1 />
      </UserProvider>
      <button onClick={changeNumberHandler}>Changing Main Component Number</button>
    </div>
  );
}

export default App;

appContext.js

App component has one button. Clicking on it updates the number state.

This is image title

Now we can observe in the above image that Comp3 and Comp4 are being re-rendered even though we have used React.memo in Comp1, while Comp2 is not being re-rendered.

This is happening because we are using an array as the value prop in our Provider.

const data = [user, changeUser]
return (
  <UserContext.Provider value={data}>
    {children}
  </UserContext.Provider>
)

So whenever App component gets re-rendered, our UserProvider component also gets re-rendered. That will create the new reference for our changeUser function as well as for our data array, which will trigger the re-render of all consumers.

To overcome this, we will use useCallback for our changeUser function and useMemo for our data array.

const changeUser = useCallback(() => {
  const randomNumber = Math.floor(Math.random() * 10 + 1);
  setRandom(randomNumber);
}, [])
const data = useMemo(() => ([
  user,
  changeUser
]), [user, changeUser])

Usage

import React, {
  useState,
  useEffect,
  useContext,
  useCallback,
  useMemo
} from 'react'
import axios from 'axios'

const UserContext = React.createContext(undefined);

const UserProvider = ({
  children
}) => {
  const [user, setUser] = useState(null);
  const [random, setRandom] = useState(1);

  useEffect(() => {
    const url = `https://jsonplaceholder.typicode.com/users/${random}`;
    axios.get(url).then(res => {
      setUser(res.data);
    }).catch(err => {
      console.error(err)
    });
  }, [random]);

  const changeUser = useCallback(() => {
    const randomNumber = Math.floor(Math.random() * 10 + 1);
    setRandom(randomNumber);
  }, [])

  const data = useMemo(() => ([
    user,
    changeUser
  ]), [user, changeUser])

  return (
    <UserContext.Provider value={data}>
      {children}
    </UserContext.Provider>
  )
}

const useUser = () => {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error('useUser can only be used inside UserProvider');
  }
  return context;
}

export {
  UserProvider,
  useUser
}

user.js

import React from 'react';
import { useUser } from './user';

const Comp3 = () => {
  const [user] = useUser();
  return (
    <div className="comp3">
      Comp3
      <hr />
      <p>{user && user.id}</p>
      <p>{user && user.name}</p>
      <p>{user && user.website}</p>
  </div>
  )
}

export default Comp3;

comp3.js

import React from 'react';
import { useUser } from './user';

const Comp4 = () => {
  const [user, changeUser] = useUser();
  return (
    <div className="comp3">
      Comp4
      <hr />
      <p>{user && user.id}</p>
      <p>{user && user.name}</p>
      <p>{user && user.website}</p>
      <button onClick={changeUser}>Change User</button>
  </div>
  )
}

export default Comp4;

comp4.js

import React from 'react';
import Comp3 from './comp3';
import Comp4 from './comp4';

const Comp2 = () => (
  <div className="comp2">
    Comp2
    <div className="compContainer">
      <Comp3 />
      <Comp4 />
    </div>
  </div>
)

export default Comp2;

comp2.js

Comp3 and Comp4 are using the useUser Hook. Also, Comp4 is calling the changeUser function when the button is clicked. When changeUser is called, the data in both Comp3 and Comp4 get changed. This is all possible because of React Context.