Moving from in-component state to using React Context
Contents
Background
When you start out with React, state is one of the first things that you learn about. You quickly become familiar with the useState
hook and learn how to manage state at a component level. And life is good ... for a while.
As you become more proficient with React, and your applications grow in complexity, it's only a matter of time before it becomes difficult to manage state at the component level. At some point you will find that you need to pass state through a few levels of components, usually because a top-level component needs access to a particular piece of state that is modified by a component that's two or three levels deep. You end up passing props through components that never actually use them simply to make them available in a 'destination' component.
This practice is commonly referred to as prop drilling
React Context
React version 16 introduced Context Hooks which the React docs refer to as a means of letting "a component receive information from distant parents without passing it as props." In other words it's a way handling state at a global level and avoiding the complications of prop drilling.
Don't assume that using Context Hooks automatically means that state is managed globally. That doesn't have to be the case: the Context in question might wrap only specific components.
I will continue to refer to it as 'global' just for the sake of simplicity.
How Context Hooks work - a high level view
The diagrams seeks to explain how React makes state available using the two different approaches: in-component useState Hooks
and 'global' Context Hooks
. The components named in the diagrams feature in an example we will look at shortly. You will see that it's not a complex example. It demonstrates multi-layered prop drilling to a small extent. I want to keep things simple and focus on the mechanism.
Having said that I hope that, even with such a small application, you get an insight into the 'inter-connectedness' between components where props have to be passed compared to the simpler, less structured situation where a 'global' Context Hook is used.
That same high level view through a React lens
React makes it possible for us to give multiple components access to state that is managed at a global level through:
- a Context Provider (part of the Context object that we will look at in more detail soon), and
- its
children
prop
The Context Provider is, in many ways, a regular React functional component that takes a children
prop. Within the component's function body we can define certain values:
- pieces of state, and
- functions that affect those pieces of state
Those values are automatically made avilable to components that are children of the Context Provider.
Going back to the diagram above, our Context Provider takes on the role of the pale red wrapper.
Now is probably a good time to look at an example.
About the example application
I introduced React Context as a way to avoid prop drilling. In an ideal world I guess we could set up a complex multi-layered application with lots of prop drilling to demonstrate how we might refactor the app to use Context Hooks to simplify state handling. But that would be too complex for an article like this.
The small example that follows focuses on a part of state that many components commonly need access to: users. You will need to use your imagination to think of ways that the prnciples shown here could have wider applications.
Example application
This small application has just three components: App.jsx
, Users.jsx
and UserItem.jsx
.
// App.jsx import { useState } from 'react'; import Users from './Users'; function App() { const [users, setUsers] = useState([]); return ( <div> <Users users={users} setUsers={setUsers} /> </div> ); } export default App;
// Users.jsx import { useEffect, useState } from 'react'; import UserItem from './UserItem'; function Users({ users, setUsers }) { const [isLoading, setIsLoading] = useState(false); useEffect(() => { fetchUsers(); }, []); const fetchUsers = async () => { setIsLoading(true); const response = await fetch('https://jsonplaceholder.typicode.com/users'); const data = await response.json(); setUsers(data.slice(0, 5)); // restrict to 5 setIsLoading(false); }; return ( <div> {isLoading ? ( <p>Loading...</p> ) : ( users.map((user) => <UserItem key={user.id} user={user} />) )} </div> ); } export default Users;
// UserItem.jsx function UserItem({ user }) { return ( <div> <p> Name: <strong>{user.name}</strong> </p> <p>Email: {user.email}</p> <p>Website: {user.website}</p> <hr /> </div> ); } export default UserItem;
Functionality
The app:
- fetches a list of users from the jsonplaceholder dummy data API, and
- renders (some of) their details
Two elements of state (users and isLoading) are handled with a useState
Hook and a fetchUsers
function is triggered by a useEffect
on first render.
Users state is held in App.jsx
because Users data is required by other components that can't be seen in this example and Users state will need to be passed down.
The rendered output
Steps to move state to UsersContext
-
Create a
context
directory insidesrc
This is not obligatory - context can live anywhere - but it makes sense to group context components together
-
In there, create a Users Context file called
UsersContext.jsx
. Note the.jsx
extension. The contents of this file are put together the same as a regular React component and it returns jsx.
Inside our Users Context file
Call the React createContext()
function (this must be imported from 'react') and assign its return value to a UsersContext
variable.
UsersContext
now contains a context object giving us access to a context Provider and a context Consumer. We will need access to UsersContext
from other modules so export it as default from this module.
Now create, and export, a UsersProvider
functional component:
- it takes a single prop:
children
- copy into it the useState hooks from
App
andUsers
- both
users
andisLoading
- remember to import
useState
from 'react'
- both
- also copy into it the
fetchUsers
function- in order to do this,
fetchUsers()
must be defined outside theuseEffect
hook and called from within it. TheuseEffect
is, incidentally, staying in theUsers
component.As an aside, it's ok to define async functions outside
useEffect
hooks simply so you can use async/await syntax.
- in order to do this,
- return a special type of component which makes use of the
Provider
property ofUsersContext
- called
<UsersContext.Provider>
- called
- pass to the Provider a prop called
value
which is an object with properties:- users
- isLoading
- fetchUsers
- and the Provider component should wrap
children
, like this:-
<UsersContext.Provider value={{ users, isLoading, fetchUsers, }} > {children} </UsersContext.Provider>
- This structure makes available (ie. Provides) all of the
value
object's properties to any component wrapped insideUsersContext.Provider
that wants to Consume them.
-
Those steps above will make more sense if you look at the finised article:
// context/UsersContext.jsx import { createContext, useState } from 'react'; const UsersContext = createContext(); export const UsersProvider = ({ children }) => { const [users, setUsers] = useState([]); const [isLoading, setIsLoading] = useState(false); const fetchUsers = async () => { setIsLoading(true); const response = await fetch('https://jsonplaceholder.typicode.com/users'); const data = await response.json(); setUsers(data.slice(0, 5)); // restrict to 5 setIsLoading(false); }; return ( <UsersContext.Provider value={{ users, isLoading, fetchUsers, }} > {children} </UsersContext.Provider> ); }; export default UsersContext;
Making use of our new UsersContext
In App.jsx
- delete the
useState
hook and theuseState
import - we don't need to pass any props to
Users
- wrap
Users
in<UsersProvider>
which needs to be imported
import Users from './Users'; import { UsersProvider } from './context/UsersContext'; function App() { return ( <UsersProvider> <Users /> </UsersProvider> ); } export default App;
In Users.jsx
- remove props from the function parentheses
- delete the
useState
hook anduseState
import - delete the
fetchUsers
function - destructure
users
,isLoading
andfetchUsers
fromuseContext(UsersContext)
- import
useContext
from 'react' - also import our
UsersContext
- import
import { useContext, useEffect } from 'react'; import UserItem from './UserItem'; import UsersContext from './context/UsersContext'; function Users() { const { users, isLoading, fetchUsers } = useContext(UsersContext); useEffect(() => { fetchUsers(); }, []); return ( <div> {isLoading ? ( <p>Loading...</p> ) : ( users.map((user) => <UserItem key={user.id} user={user} />) )} </div> ); } export default Users;
UserItem.jsx
This is unaffected. It still receives the user
prop from Users
.
Other components
Any other components that need access to users
state can be wrapped in the <UsersProvider>
component and destructure users
from useContext
.
Conclusion
We have refactored our small application to make use of a Context Hook to handle state globally rather than inside individual components. The app functions exactly the same as it did before the refactoring.
The main effect is that we no longer need to pass props around simply to make a particular piece of state available to a particular component.
React Context doesn't have to operate at a global level. In many cases we will want to use a Context Provider to wrap only a specific selection of components that need access to one or more specific state variables.
A further improvement that we could make to our UsersContext
file would be to use Reducers to manage state rather than useState
hooks. That will be the subject of another article.