Go Back

Deep dive into React Context API

Posted on October 7, 2022|12 min read

In this article, we are going to dive deep into React Context API. We will look into:

  • Why React Context is needed,
  • How to use it in functional and class components,
  • When and when not to use it,
  • Its limitations and how to tackle it using different patterns and,
  • Best practices while using React Context

Let's dive into it.

The primary way to pass data from parent to child components in React is by using props. React props make passing data from parent components to child components simple and intuitive. However, in cases where you need to share data from parent to deeply nested child components, using props can be tedious and can lead to code that is less readable and difficult to maintain.

For example, if you have a data in a component and you need to use it in a grandchild component, you need to pass the data through the child component and then into the grandchild.

Prop drilling

This process of passing data through multiple levels of the component tree is called prop drilling.

Prop drilling is not necessarily a problem in cases where components tree is small and I feel it is a better approach to deal with data sharing among child components in this scenario.

But if you have a data that can be considered as global to a particular components tree and components tree is huge, then using passing data using props might not be a good idea because In components tree, you may have multiple levels of components that don't use that particular piece of data but have to keep that data as props so that it can just pass it along to their descendants.

Consider this example:

const ComponentA = () => { const data = { title: "hello"}; return <ComponentB data={data}>; }; const ComponentB = ({ data }) => { return ( <> <div>Component B</div> <ComponentC data={data}> </> ) }; const ComponentC = ({ data }) => { return ( <> <div>Component C</div> <ComponentD data={data}> </> ) }; const ComponentD = ({ data }) => { return ( <> <div>Component D</div> <div>{data.title}</div> </> ) };

Here in above example, we have Component A, B, C and D. Component A is parent of Component B, Component B is parent of Component C and Component C is parent of Component D. We need to pass a prop data from Component A to Component D. We need to pass it from A to B, B to C and finally, C to D. This requires drilling a prop through component B and component C unnecessarily. You can imagine what it would be like if your application is complex. Due to the large component tree, managing the application through props drilling will be extremely challenging.

React Context lets you avoid prop drilling. Let's see how.

Introducing React Context

React’s Context API offers a more convenient way of passing data between components. Implementing Context is a three-step process:

  • Creating a Context Object
  • Creating a Provider
  • Consuming Context data

Creating a Context Object

To create a context, use React.createContext :

const UserContext = React.createContext(defaultContextValue);
  • React.createContext returns a context object which includes the Provider and Consumer components.
  • The defaultContextValue argument is the data that will be available to its descendants if there isn't a matching Provider.

Creating a Provider

  • A Context Provider is a component that publishes changes in its context data to its descendant components.
  • It takes an attribute called value which overrides the default value set in React.createContext.
<UserContext.Provider value={value}>

To simplify your code and make reuse of a Provider easier, it's common practice to create a higher-order component that renders a Provider component and its children:

import React from 'react'; import { UserContext } from 'contexts/UserContext'; const UserContextProvider = ({ children }) => { const [currentUser, setCurrentUser] = React.useState(null); const [language, setLanguage] = React.useState("en"); React.useEffect(async () => { // get current logged-in user data and preferred language from API const {user, lang} = await fetch("/userData").then(data => data.json()); setCurrentUser(user); setLanguage(lang); }, []); const contextValue = { currentUser, language, }; return ( <UserContext.Provider value={contextValue}> {children} </UserContext.Provider> ); }; export default UserContextProvider;

Now to use it, wrap it around the component(s) that need access to its value:

import UserContextProvider from 'providers/userContextProvider'; const App = () => { return ( <UserContextProvider> <Header /> <Body /> <Footer /> </UserContextProvider> ); }

The Provider component can be used as many times as you need, and it can be nested. Components that use a Context will access the closest Provider ancestor or will use the Context's default value if there isn't a Provider ancestor.

Consuming Context data

Once you have a Context and a Provider, descendant components can become Consumers of the Context. When a Context provider has a new Context value, all Context consumers receive the new value and re-render. This means the value in the provider is propagated to all the consumers.

Class Component

There are two ways to consume Context in Class Components:

  • Set the contextType property on the class
  • Use the Context.Consumer component
Set the contextType property on the class
import React from 'react'; import { UserContext } from 'contexts/UserContext'; class Header extends React.Component { static contextType = UserContext; render() { return ( <header> <div>User: {this.context.currentUser.name}</div> <div>Selected Language: {this.context.language}</div> <header> ) } } export default Header;

The drawback of consuming context with this approach is you can only use one Context in a class.

If your component needs to use multiple contexts, you can use second approach.

Use the Context.Consumer component
import React from 'react'; import { UserContext } from 'contexts/UserContext'; class Header extends React.Component { static contextType = UserContext; render() { return ( <UserContext.Consumer> {(context) => {( <header> <div>User: {context.currentUser.name}</div> <div>Selected Language: {context.language}</div> <header> )}} </UserContext.Consumer> ) } } export default Header;

Consider you have one more Context called ThemeContext which holds theme data set for the application i.e. dark or light, you can use it along with UserContext like this:

import React from 'react'; import { UserContext } from 'contexts/UserContext'; class Header extends React.Component { static contextType = UserContext; render() { return ( <ThemeContext.Consumer> {({theme}) => ( <UserContext.Consumer> {({currentUser, language}) => {( <header> <div>User: {currentUser.name}</div> <div>Selected Language: {language}</div> <div>{theme} mode is on</div> <header> )}} </UserContext.Consumer> )} </ThemeContext.Consumer> ) } } export default Header;

Functional Components

There are two ways to consume Context in functional components:

  • React.useContext hook
  • Context.Consumer component as explained previously in class component

Here is how you can use React.useContext hook:

import React from 'react'; import { UserContext } from 'contexts/UserContext'; const Header = () => { const { currentUser, language } = React.useContext(UserContext); const { theme } = React.useContext(UserContext); return ( <header> <div>User: {currentUser.name}</div> <div>Selected Language: {language}</div> <div>{theme} mode is on</div> <header> ); } export default Header;

When to use React Context

Simple answer is for managing global data. Global data could be either

  • data used all over the application like user is authenticated or not, selected theme/language or
  • data which needs to be accessed by multiple components having different nesting levels.

When not to use React Context

  • When your data is not global and your component tree is small. Simply using props could be good idea.

  • Using Context in your component makes it dependent on global state, which in turn makes it less reusable. If your component is designed to be reused across different places, it is recommended to avoid coupling it with global state using context. You can use composition pattern to pass data from parents to children components if you want to avoid prop drilling and context.

Limitations of using Context ( in case of objects )

When using objects as context value, your context consumers might re-render unnecessary because object may contain many properties and consumers may not use all of them.

To understand this, consider this scenario:

Consider we have a context UserContext having an object as a value with type:

type UserContextType = { name: string; age: number; setName: Dispatch<SetStateAction<string>>; setAge: Dispatch<SetStateAction<number>>; } const UserContext = React.createContext<UserContextType>({ name: '', age: 0, setName: () => {}, setAge: () => {}, })

We have two consumer components DisplayUserName and DisplayUserAge which renders and changes user's name and age respectively:

const DisplayUsername = () => { const { name, setName } = React.useContext(UserContext); const onNameChange = (event) => setName(event.target.value); return ( <> <div>Hello, ${name}!!</div> <input type="text" placeholder="enter your name" onChange={onNameChange}> </> ) } const DisplayUserAge = () => { const { age, setAge } = React.useContext(UserContext); const onAgeChange = (event) => setAge(event.target.value); return ( <> <div>You are ${age} years old!</div> <input type="number" placeholder="enter your age" onChange={onAgeChange}> </> ) }

We have a DisplayUser component which holds both components:

const DisplayUser = () => { return ( <> <DisplayUsername /> <DisplayUserAge /> </> ); };

Finally, our App component contains user state and provides value to the user context:

const App = () => { const [name, setName] = React.useState(''); const [age, setAge] = React.useState(''); return ( <UserContext.Provider value={{name, age, setName, setAge}}> <DisplayUser /> </UserContext.Provider> ); }

You can notice that Component DisplayUsername and DisplayUserAge are totally separate component which rely on name and age property of context respectively. Ideally, DisplayUsername should only re-render when name is changed and DisplayUserAge should only re-render when age is changed.

When user changes "name" using name input, it will result in creation of new context object and both Components DisplayUsername and DisplayUserAge will re-render although only name value in the context has been changed, not age. Thus DisplayUserAge will experience extra unneccessary re-render.

Similar case will happen when user changes age value and DisplayUsername will also perform extra re-render. This is the extra re-render limitation in the behaviour that we should be aware when using React Context.

Note that Extra re-renders are a pure overhead that should be avoided technically. Although this would be fine unless it is impacting user experience and performance of the application. In most of the time, user might not notice a few extra re-renders. React is highly optimised to handle frequent re-renders. Overengineering to prevent re-rendering results in making the code complex and less readable and is not worth it unless there is a performance concern.

Patterns for implementing a global state with Context

  • Creating small state pieces

  • Creating one state with useReducer and propagating with multiple Contexts

Creating small state pieces

The first solution is to split a global state into pieces. So, instead of using a big combined object, create a global state and a Context for each piece.

let's see how we can implement this pattern using UserContext example.

type UserNameContextType = [string, Dispatch<SetStateAction<number>>]; type UserAgeContextType = [number, Dispatch<SetStateAction<number>>]; const UserNameContext = React.createContext<UserNameContextType>( ["", () => {}] ); const UserAgeContext = React.createContext<UserAgeContextType>( [0, () => {}] ); const UserNameContextProvider = ({ children }) => { return ( <UserNameContext.Provider value={React.useState("")}> {children} </UserNameContext.Provider> ); } const UserAgeContextProvider = ({ children }) => { return ( <UserAgeContext.Provider value={React.useState(0)}> {children} </UserAgeContext.Provider> ); } // Only uses UserNameContext const DisplayUsername = () => { const [ name, setName ] = React.useContext(UserNameContext); return ( <> <div>Hello, ${name}!!</div> <input type="text" placeholder="enter your name" onChange={e => setName(e.target.value)} /> </> ); } // Only uses UserAgeContext const DisplayUserAge = () => { const [ age, setAge ] = React.useContext(UserAgeContext); return ( <> <div>You are ${age} years old!</div> <input type="number" placeholder="enter your age" onChange={e => setAge(e.target.value)} /> </> ); } const DisplayUser = () => { return ( <> <DisplayUsername /> <DisplayUserAge /> </> ); }; const App = () => { return ( <UserNameContextProvider> <UserAgeContextProvider> <DisplayUser /> </UserAgeContextProvider> </UserNameContextProvider> ); }

Now, with this implementation, our application doesn't suffer from extra re-render limitation. Both Contexts hold only primitive values. There is a separation of concerns as DisplayUsername only depends on UserNameContext and re-renders only when name changes. Similarly now DisplayUserAge only depends on UserAgeContext and re-renders only when age change.

You can also notice that this pattern results in having more Provider components which leads to deeper component nesting and results in bad readabiltiy.

If you are sure that an object is used at once and the usage doesn't hit the limitation of the Context behavior, putting an object as a Context value is totally acceptable. Also, sometime it doesn't make sense to split a state into multiple pieces and separate contexts. Using a single context would be better approach for maintainability. For example:

const [user, setUser] = useState({ firstName: 'Altamash', lastName: 'Ali' });

let's look into second pattern.

Creating one state with useReducer and propagating with multiple Contexts

Another pattern is to create a single state and use multiple Contexts to distribute state pieces. In this case, distributing a function to update the state should be done with a separate Context.

First let's create Contexts and a common dispatch context to update context value:

type ActionType = "UPDATE_USER_NAME" | "UPDATE_USER_AGE"; type Action = {type: ActionType, payload: string | number} const UserNameContext = React.createContext<string>(""); const UserAgeContext = React.createContext<string>(0); const DispatchContext = React.createContext<Dispatch<Action>>( () => {} );

Now, let's create a UserProvider which holds multiple providers and create a single state to hold user state using useReducer hook:

type UserState = { name: string, age: number }; const userReducer = (state: UserState, action: Action) => { switch(action.type) { case 'UPDATE_USER_NAME': return { ...state, name: action.payload }; case 'UPDATE_USER_AGE': return { ...state, age: action.payload }; default: return state; } } const UserProvider = ({ children }) => { const [state, dispatch] = React.useReducer(userReducer, {name: "", age: 0}); return ( <DispatchContext.Provider value={dispatch}> <UserNameContext.Provider value={state.name}> <UserAgeContext.Provider value={state.age}> {children} </UserAgeContext.Provider> </UserNameContext.Provider> </DispatchContext.Provider> ); }

Now, DisplayUsername and DisplayUserAge components will look like:

const DisplayUsername = () => { const name = React.useContext(UserNameContext); const dispatch = React.useContext(DispatchContext); const onNameChange = (event) => dispatch({ type: 'UPDATE_USER_NAME', payload: event.target.value }); return ( <> <div>Hello, ${name}!!</div> <input type="text" placeholder="enter your name" onChange={onNameChange} /> </> ); } const DisplayUserAge = () => { const age = React.useContext(UserAgeContext); const dispatch = React.useContext(DispatchContext); const onAgeChange = (event) => dispatch({ type: 'UPDATE_USER_AGE', payload: event.target.value }); return ( <> <div>You are ${age} years old!</div> <input type="number" placeholder="enter your age" onChange={onAgeChange} /> </> ); }

DisplayUser component will remain same. Our App component will look like:

const DisplayUser = () => { return ( <> <DisplayUsername /> <DisplayUserAge /> </> ); }; const App = () => { return ( <UserProvider> <DisplayUser /> </UserProvider> ); }

That's the implementation of this pattern. This approch also doesn't suffer from extra re-renders. Although the code looks complex because of useReducer and multiple providers nesting but now we can have single state which holds state of related data (here user details) and to ensure separation of concern, now we have multiple contexts for different state values which is primitive value (easier to do value change comparison compared to object or reference type). There is a separate context for dispatch function as well.

The main idea behind these two patterns is to prevent extra re-rendering by using multiple contexts and ensuring separation of concern.

Let's learn some best practices when using React Context.

Best Practices when using Context

  • Creating custom hooks to access Context Values and provider components
  • Factory pattern with a custom hook
  • Avoiding provider nesting with reduceRight

Creating custom hooks to access Context Values and provider components

Currently, we are using useContext hook directly to get context values. Instead, we can create custom hooks to access Context values as well as provider components. This approach allows us to hide context and impose restriction on its usage.

For example, we can create a custom hook useUserName to access UserNameContext:

const UserNameContext = React.createContext<string | undefined>(); const useUserName = () => { const name = React.useContext(UserNameContext); if (value === undefined) { throw new Error("Wrap your component with UserNameContextProvider"); } return name; }

Now, in our DisplayUsername component, we can directly use this custom hook:

const DisplayUsername = () => { const name = useUserName(); return ( <div> Hello ${name}! </div> ); }

Factory pattern with a custom hook

Creating a custom hook and a provider component is a somewhat repetitive task, we can create a factory function that does the task.

/* factory function to create context */ const contextStateFactory = (useValue: (init) => State) => { const Context = React.createContext(); const ContextProvider = ({ initialValue, children }) => { return ( <Context.Provider value={useValue(initialValue)}> {children} </Context.Provider> ); } const useContextState = () => { const value = React.useContext(Context); if (value === undefined) { throw new Error("Provider missing"); } return value; } return [ContextProvider, useContextState] as const; }

Factory function contextStateFactory accepts a custom hook useValue which takes initial value as argument and returns a state object. It returns a tuple containing first value as Provider and second value as custom hook to access context value.

Let's see how to use this factory function:

const useString = (init) => useState(init || ""); const useNumber = (init) => useState(init || 0); const [UserNameProvider, useUserName] = contextStateFactory(useString); const [UserAgeProvider, useUserAge] = contextStateFactory(useNumber); const DisplayUsername = () => { const name = useUserName(); return ( <div> Hello ${name}! </div> ); } const DisplayUserAge = () => { const age = useUserAge(); return ( <div> You are ${age} years old! </div> ); } const DisplayUser = () => { return ( <> <DisplayUsername /> <DisplayUserAge /> </> ); }; const App = () => { return ( <UserNameProvider> <UserAgeProvider> <DisplayUser /> </UserAgeProvider> </UserNameProvider> ); }

We can see that how it eliminate repetitive task of creating provider and custom hook and made it easy to create multiple contexts with ease.

Avoiding provider nesting with reduceRight

In complex applications, App might contain huge provider nesting like this:

const App = () => ( <Provider1 value={10}> <Provider2 value={20}> <Provider3 value={30}> <Provider4 value={40}> <Provider5 value={50}> <MyComponent /> </Provider5> </Provider4> </Provider3> </Provider2> </Provider1> );

We can refactor this using reduceRight method to eliminate this deep nesting in the code:

const providers = [ [ Provider1, { value: 10 } ], [ Provider2, { value: 20 } ], [ Provider3, { value: 30 } ], [ Provider4, { value: 40 } ], [ Provider5, { value: 50 } ], ] as const; const App = () => { return providers.reduceRight( (children, [Component, props]) => React.createElement(Component, props, children), <MyComponent />, ); };

That's all about React Context 🤯.

Happy React!