|
An excerpt from React Quickly, Second Edition by Morten Barklund This excerpt explores using React Context. Read it if you’re a React developer or want to learn more about React. |
Take 25% off React Quickly, Second Edition by entering fccmardan2 into the discount code box at checkout at manning.com.
Check out part 1 here.
React context deconstructed
Let’s take a step back and look at React context in close detail. As mentioned, to use React context, you need to create a provider and a consumer. You can create consumers in two different ways, either as a hook or by using a “render prop”. But before you can do any of this, you need to create the context itself. You do that by using the function createContext
which exists in the React package:
import { createContext } from 'react'; const MyContext = createContext(defaultValue);
Two things to note here:
- It is common to name the context variable with a capital letter as it kind of serves the purpose of a React component (or at least the properties in it do).
createContext
takes a single argument, which is the default value. We will get back to how that plays out in just a little bit.
Consuming a context
When you have a context variable, e.g. MyContext above, it has two properties, which are what we care about: MyContext.Provider and MyContext.Consumer.
We have already explained how you can consume a context using the useContext hook. You can do a similar thing with the MyContext.Consumer property, but it is a bit trickier.
Let’s say you want to display a paragraph with the name provided by the nearest name context in a component named DisplayName. We can do that using the useContext hook as shown below:
function DisplayName() { const name = useContext(NameContext); return <p>{name}</p> }
This is pretty simple. We invoke the hook and get the current value back as a variable, we can directly use in the component.
If we try to do the same thing using the Consumer
component, we have to invoke the consumer component with a function as the first and only child and that function will be invoked with the value of the component:
function DisplayName() { return ( <p> <NameContext.Consumer> {(name) => name} </NameContext.Consumer> </p> ); }
You can probably see how this is a lot more work to type, and if we need to do some calculations or logic with the returned value, we have to restructure our component quite a bit.
Using the Consumer
component is quite rare in functional codebases. It is probably only used in older class-based components.
Context composition
The provider is used to create a context that can be consumed. The consumer is used to consume the nearest provided context. Note that you can provide the same context many times through your application, and you can even provide the same context nested. You can also use the same context many times, even outside any provider.
When you consume a context, you will get the value provided by the nearest provider going up the document. If no provider exists above the consumer, you will get the default value as defined when the context we created. Let’s illustrate all of this in figure 10.
Figure 10 You can have many providers and consumers of the same context.
A few things to note in figure 10:
- If you consume a context that does not have a provider above it, as in
TopComponent
, you will just get the default value from the definition of the context (0 in this case). - If you consume a context that has multiple providers above it, as in
BottomComponent
, you will get the value from the nearest provider looking up through the document tree (e.g. 17 rather than 2, in this case).
Nested context example
You can imagine a use-case for nested contexts for UI variables. For instance, let’s imagine an app where we have buttons with different border widths throughout the application.
Our web application is a webshop with different items for purchase and pages about the business. We have some buttons in the header and again in the footer. And we have buttons to open the shopping cart in both the header and the footer.
By default, all buttons have a border width of 1 pixel, but in the footer all buttons have a border width of 2 pixels. Furthermore, every time we have a button to go to the shopping cart, the button must always have a border width of 5 pixels because it is a really important button.
Let’s try to sketch this system first in figure 11.
Figure 11 Our component tree for our shopping website. Note how we have both a default context value and several context providers throughout.
Now, every button component will look up the component tree to find the nearest border context provider and use the border width taken from there. If no provider is found up the tree, the button will use the default value as defined in the original context creation.
Let’s annotate the tree with all these lookups for the nearest provider in figure 12.
Figure 12 The component tree with the nearest provider (or the root) marked with a heavier arrow for every button component as well as the border width resolved for that component.
Let’s go ahead and implement all of this, as we have all the information we need. We can do so in listing 5.
Listing 5 Border width by context.
import { useContext, createContext } from 'react'; const BorderContext = createContext(1); #A function Button({ children }) { const borderWidth = useContext(BorderContext); #B const style = { border: `${borderWidth}px solid black`, background: 'transparent', }; return <button style={style}>{children}</button> } function CartButton() { return ( <BorderContext.Provider value={5}> #C <Button>Cart</Button> </BorderContext.Provider> ) } function Header() { const style = { padding: '5px', borderBottom: '1px solid black', marginBottom: '10px', display: 'flex', gap: '5px', justifyContent: 'flex-end', } return ( <header style={style}> <Button>Clothes</Button> <Button>Toys</Button> <CartButton /> </header> ) } function Footer() { const style = { padding: '5px', borderTop: '1px solid black', marginTop: '10px', display: 'flex', justifyContent: 'space-between', } return ( <footer style={style}> <Button>About</Button> <Button>Jobs</Button> <CartButton /> </footer> ) } function App() { return ( <main> <Header /> <h1>Welcome to the shop!</h1> <BorderContext.Provider value={2}> #D <Footer /> </BorderContext.Provider> </main> ); }
#A We create the initial context with a default value of 1
#B In the button component, we consume whatever value is provided by the nearest provider and use that as the border width property in CSS
#C We add a border width provider around the button inside the cart button to provide this button with exactly 5px
#D We surround the footer with a provider that makes sure that all buttons inside that by default will have 2px borders, unless another more specific provider tells them otherwise.
npx create-react-app rq10-border-context --template rq10-border-context
Upon opening this in the browser we’re treated with figure 13.
Figure 13 Our shop website shows all the buttons with the correct widths exactly as designed. It doesn’t look good, but it’s what the client wanted for some reason!
Taking context to the next level
So far we’ve put numbers and strings into contexts, but nothing is stopping us from putting objects or functions in there instead. In fact, you can even put a complex object consisting of a number of other values in there with a mix of all sorts of types.
A common approach is to use a context as a delivery mechanism for stateful values and setters.
For instance, let’s imagine we have a website with dark mode and light mode. We have a button in the header that can toggle between the two. All relevant components will look at the current state and change their design depending on this state value.
We want to put two things into the state: A value that tells if we currently are in dark mode or not (isDarkMode
), and a function that allows a button to toggle between the two modes (toggleDarkMode
). We can put these two values in a single object and stuff that into the context as the value.
We will sketch this system in figure 14.
Figure 14 A document tree sketch of our website with a dark mode / light mode toggle. Note how we pass in an object to the context provider, which we can then deconstruct and use wherever we need either of the two values in the components in the entire document tree below the provider.
Let’s go ahead and implement this in listing 6.
Listing 6 Dark mode with context.
import { useContext, useState, createContext, memo } from 'react'; const DarkModeContext = createContext({}); #A function Button({ children, ...rest }) { const { isDarkMode } = useContext(DarkModeContext); #B const style = { backgroundColor: isDarkMode ? '#333' : '#CCC', border: '1px solid', color: 'inherit', }; return <button style={style} {...rest}>{children}</button> } function ToggleButton() { const { toggleDarkMode } = useContext(DarkModeContext); #C return <Button onClick={toggleDarkMode}>Toggle mode</Button> } const Header = memo(function Header() { const style = { padding: '10px 5px', borderBottom: '1px solid', marginBottom: '10px', display: 'flex', gap: '5px', justifyContent: 'flex-end', } return ( <header style={style}> <Button>Products</Button> <Button>Services</Button> <Button>Pricing</Button> <ToggleButton /> </header> ) }); const Main = memo(function Main() { #D const { isDarkMode } = useContext(DarkModeContext); #B const style = { color: isDarkMode ? 'white' : 'black', backgroundColor: isDarkMode ? 'black' : 'white', margin: '-8px', minHeight: '100vh', boxSizing: 'border-box', } return <main style={style}> <Header /> <h1>Welcome to our business site!</h1> </main> }); function App() { const [isDarkMode, setDarkMode] = useState(false); #E const toggleDarkMode = () => setDarkMode(v => !v); #E const contextValue = { isDarkMode, toggleDarkMode }; #F return ( <DarkModeContext.Provider value={contextValue}> #G <Main /> </DarkModeContext.Provider> ); }
#A This time we initialize our context with an empty object. This is because we know we will always have a context at the root of the application, so the default values will never be used.
#B In these two location we use only the isDarkMode flag from the context
#C In the toggle button, we use only the toggleDarkMode function from the context
#D We memoize the main component
#E In the main application component we define the two values that go into our context
#F We put these two values together in a single object
#G And we use this single object as the value for our context provider
npx create-react-app rq10-dark-mode --template rq10-dark-mode
We can observe this website in action in figure 15.
Figure 15 Our website in both light mode and dark mode respectively.
The important things to note here are definitely how we provide this context with two different properties in lines #E and #F in the example, but also how we memoize some components inside the context provider in line #D. This is very important to do, because our main App component will re-render every time the context changes, which is every time the dark mode flag toggles (because the state updates). However, we don’t want all the other components to re-render just because the context does. In this instance, the Main component consumes the context, so it will re-render every time the context updates, but the Header does not consume the context, so it should not re-render. And with our use of memoization, it doesn’t, so that’s basically perfect.
And we don’t have to stop there. We can put a whole bunch of properties and functions into the context value. In fact, there’s a whole concept around using contexts for functionality providers called the Provider Pattern. There’s a lengthy article on this pattern over at barklund.dev/provider with a lot more detail.
Context selector
There is a minor suboptimal issue with our context provider in the previous example. The issue is, that all components consuming a specific context will re-render, when any value inside that context changes.
That’s because our context is now a complex object with multiple properties, but React doesn’t really care about that. React just sees that the context value changes, so every component using that context will be re-rendered.
However, our toggle component never needs to re-render. The toggle component uses a function that could be memoized to be completely stable. The toggleDarkMode
function does not depend on the current value of the context, so it can be memoized to be stable.
We unfortunately cannot tell React to only re-render a specific component, when specific properties of a context update. At least, we cannot do that yet. That is something that is definitely coming in a future update to React, but it is not there yet. It was expected to have come with React 18, but did not make it in.
So if we want to do this we need to use an external library. This library is called use-context-selector
and allows us to not just use an entire context every time, but specify which specific attribute on the context we are interested in (we “select” the relevant property, hence the selector name), and React will now only re-render our component when that specific property changes.
To use the use-context-selector
package correctly, we however also need to create our context using this package. We cannot use the regular context as created by createContext
in the React package, but instead we have to use the createContext
function provided by the use-context-selector
package.
Let’s go ahead and implement this updated and more optimized version of our dark mode toggling website in listing 7.
Listing 7 Dark mode with context selector.
import { useState, useCallback, memo } from 'react'; import { createContext, useContextSelector } from 'use-context-selector'; #A const DarkModeContext = createContext({}); function Button({ children, ...rest }) { const isDarkMode = useContextSelector( #B DarkModeContext, (contextValue) => contextValue.isDarkMode, #C ); const style = { backgroundColor: isDarkMode ? '#333' : '#CCC', border: '1px solid', color: 'inherit', }; return <button style={style} {...rest}>{children}</button> } function ToggleButton() { const toggleDarkMode = useContextSelector( #B DarkModeContext, (contextValue) => contextValue.toggleDarkMode, #C ); return <Button onClick={toggleDarkMode}>Toggle mode</Button> } const Header = memo(function Header() { const style = { padding: '10px 5px', borderBottom: '1px solid', marginBottom: '10px', display: 'flex', gap: '5px', justifyContent: 'flex-end', } return ( <header style={style}> <Button>Products</Button> <Button>Services</Button> <Button>Pricing</Button> <ToggleButton /> </header> ) }); const Main = memo(function Main() { const isDarkMode = useContextSelector( #B DarkModeContext, (contextValue) => contextValue.isDarkMode, #C ); const style = { color: isDarkMode ? 'white' : 'black', backgroundColor: isDarkMode ? 'black' : 'white', margin: '-8px', minHeight: '100vh', boxSizing: 'border-box', } return <main style={style}> <Header /> <h1>Welcome to our business site!</h1> </main> }); function App() { const [isDarkMode, setDarkMode] = useState(false); const toggleDarkMode = useCallback(() => setDarkMode(v => !v), []); #D const contextValue = { isDarkMode, toggleDarkMode }; return ( <DarkModeContext.Provider value={contextValue}> <Main /> </DarkModeContext.Provider> ); }
#A We have only changed a few things this time around. We import two functions from the use-context-selector package
#B Whenever we need a value from the context, we use the new useContextSelector hook rather than the normal useContext hook.
#C Besides the context to select from, we also provide a function that specifies exactly what part of the context we’re interested in. React will now make sure to only re-render this component, when this specific part of the context updates.
#D Finally we need to memoize our toggle function using useCallback
npx create-react-app rq10-dark-mode-selector --template rq10-dark-mode-selector
The result of this is exactly the same website as we had before with exactly the same functionality – except now the ToggleButton
never re-renders, because it only uses a stable value from the context, which doesn’t ever update so there’s no need to re-render the component. The two components listening for the isDarkMode
flag inside the context will still re-render every time the flag updates, because we select that exact property in the useContextSelector
hooks in those two components.
This might seem like an over-optimization at this point, because we’re talking about whether a single component updates a few extra times or not. However, in a large application with many contexts, this adds up. And it adds up quickly! So if you are using contexts to share common functionality throughout your application, you should be using useContextSelector
rather than the normal useContext
hook. Unless React has already implemented the selection logic as part of the normal useContext
hook by the time you’re reading this.
How useful is this?
This seems like a minor pattern that might be smart to use for some functionality, but it is a lot more than that. This pattern is actually possible to use as a single way to distribute and organize data and functionality throughout your entire application.
Your application can have dozens of different contexts on many different layers working on top of each other providing global and local functionality to parts or all of your application. You can, for instance, have your user authorization with the current user information as well as methods to login and logout in one context, have your application data in a second context, and have data controlling the UI in a third context.
If used correctly, this is the most powerful pattern in your React quiver, which you can apply to almost any application. It is extremely generic and versatile, and can be applied to any situation; and it is customizable enough to be useful for many different constructions.
Some might argue that rather than using React Context with useContextSelector
to manage complex state throughout your application, it is better to use an established tool such as redux-toolkit
to provide this functionality. But truth be told: redux-toolkit
uses this exact functionality under the hood to provide its magic, so you’re getting the exact same performance using either method.
That’s all for this article. Thanks for reading.