Compound Components in React: Using the Context API
This post is meant to guide you through a working example on CodeSandbox. I recommend following along on a desktop. ๐พ
In the first post of this series I introduced compound components, a group of components that work in tandem to produce some common functionality. I explained how to:
- add "sub-components" to a parent using the
static
keyword - loop through the direct children of a component using
React.Children.map
- identify specific children using the
displayName
property - edit children by passing them additional props using
React.cloneElement
These techniques provide a unique ability to abstract away irrelevant implementation details, resulting in a clean API for the end user.
Revisiting drawbacks
Even though we accomplished what we set out to do in that first post, the solution was quite fickle. Here are a few inconvenient truths posed as questions that you may or may not have asked yourself while following along.
"I have to use this strange
displayName
property to identify a component?"
"I can only access direct children inside
React.Children.map
?"
"I have to clone the component I want to pass data to? That doesn't sound good for performance."
In my opinion, the biggest drawback surrounds React.Children.map
. Not only
does it bloat the render
method, but it also has the limitation of only
giving you access to the direct children of the component you're rendering.
{
React.Children.map(this.props.children, (child) => {
/* only direct children */
/* if statements everywhere! */
if (child.type.displayName === 'Thing') {
}
if (child.type.displayName === 'OtherThing') {
}
});
}
This isn't a long term solution. An alternative one would need to handle the following use cases:
- Children should be accessible at all levels of the component tree
- A child should be able to explicitly subscribe to a piece of state
- A cloning process should not be required to pass data down to children
Does such a solution exist?
React's Context API
Enter the Context API, a new addition to the React library in version 16.3. The API allows a component to pass data down to any of its children, whether they are direct or indirect. The official React docs give a great description of what Context is meant for:
Context is designed to share data that can be considered โglobalโ for a tree of React components
There are a slew of great tutorials on this topic, but the offical docs are, in my opinion, the most helpful. I recommend pausing here and brushing up on the concept before moving forward.
Getting started
I've created a starter template on
CodeSandbox. It starts exactly where we left off in the
last post, with a
working implementation of a basic compound component named Chat
. If you
haven't read that post it may be helpful to do so in order to gain some
context. ๐
In src/index.js
the Chat
component is being rendered with three children
components. Together they make up a single compound component.
// src/index.js
<Chat>
<Chat.Messages />
<Chat.Input />
<Chat.Button />
</Chat>
Because of the nature of this example, these components don't come with a bundle of options that can be passed as props. The surface API is practically non-existent, however, it should be obvious to anyone reading the code what is happening here.
Creating context
To start, open up src/components/Chat.js
and edit the file so that you are
importing the createContext
method from the react
library. Call the method
near the top of the file and set the result equal to a new variable named
ChatContext
.
// src/components/Chat.js
import React, { Component, createContext } from 'react';
// ...
const ChatContext = createContext();
The createContext
method returns an object containing a Provider
and
Consumer
pair. The former exposes data to the latter.
The Chat
component will need to be refactored so that it renders the
Provider
. This means that the unsightly React.Children.map
method can
finally take hike:
// src/components/Chat.js
// ...
render() {
const { children } = this.props
return (
<ChatContext.Provider>
<h1>Chatroom</h1>
{children}
</ChatContext.Provider>
)
}
The change above will cause the app to error, but don't sweat it. The fix will be arriving shortly.
Providing context
The ChatContext.Provider
requires a single prop named value
. This prop can
be thought of simply as the actual context being provided, and any underlying
Consumer
s will have access to it.
<Provider value={context}>/* consumers can access context here */</Provider>
In order to know what the value
prop should be, take a look at all of the
props that the sub-components of Chat
currently require in order to function.
Messages
requires amessages
propInput
requires avalue
prop and anonChange
propButton
requires anonClick
prop
The values of these props all exist somewhere in the Chat
component, either in
this.state
or as class methods. We can combine them into a single object and
assign it to the value
prop for ChatContext.Provider
. This can be done in
one of two ways.
The first way is to simply create a new object, we'll call it context
, at the
top of the render
method with all the necessary data inside of it. Then we can
pass that object to ChatContext.Provider
:
// src/components/Chat.js
// ...
render() {
const { children } = this.props;
const { messages, currentMessage } = this.state;
const { updateCurrentMessage, add } = this;
const context = {
messages,
currentMessage,
updateCurrentMessage,
add
}
return (
<ChatContext.Provider value={context}>
<h1>Chatroom</h1>
{children}
</ChatContext.Provider>
)
}
Although this works, there is a significant downside. The context
object must
be re-created every time the Chat
component renders, even if none of the
values inside of the object have changed. This will inevitably cause some
unnecessary re-rendering of the components below.
The second and more performant way to handle this would be to add the methods
to the state
object of Chat
, and pass the state
directly to
ChatContext.Provider
:
// src/components/Chat.js
class Chat extends Component {
// ...
updateCurrentMessage = event => {/* */};
add = () => {/* */};
state = {
currentMessage: "",
messages: [],
updateCurrentMessage: this.updateCurrentMessage,
add: this.add
};
render() {
const { children } = this.props
<ChatContext.Provider value={this.state}>
{children}
</ChatContext.Provider>
}
}
The first time I saw this my left eye started to twitch. It's weird. I get it. But this prevents any underlying sub-components from re-rendering unnecessarily.
Consuming context
The last step in this refactor is to update the sub-components of Chat
so that
they consume the context created earlier instead of relying on props. In order
for this to happen we'll first need to export ChatContext.Consumer
out of
Chat.js
.
// src/components/Chat.js
// ...
export const ChatConsumer = ChatContext.Consumer;
In each sub-component you can now import ChatConsumer
and render it as the
root element of each.
// src/components/Messages.js
import React from 'react'
import { ChatConsumer } from './Chat'
const Messages = () => (
<ChatConsumer>
{({ messages }) => (/* render Messages */)}
</ChatConsumer>
)
// src/components/Input.js
import React from 'react'
import { ChatConsumer } from './Chat'
const Input = () => (
<ChatConsumer>
{({ currentMessage, updateCurrentMessage }) => (/* render Input */)}
</ChatConsumer>
)
// src/components/Button.js
import React from 'react'
import { ChatConsumer } from './Chat'
const Button = () => (
<ChatConsumer>
{({ add }) => (/* render Button */)}
</ChatConsumer>
)
Note: The Consumer
returned by the createContext
method uses a
render prop. Familiarity with this
render prop pattern will definitely be of use here.
Before this refactor, the sub-components of Chat
relied on props passed in
during the React.cloneElement
process. Now, instead of mapping through each
child and cloning them, they can each explicitly declare what data they need
from the Provider
above. This data is a drop-in replacement for the props that
were being used before, albeit with a few name changes.
For instance, Button
used to expect an onClick
prop, which was a reference
to the add
method on the Chat
component. Now it gets direct access to add
via Context:
// Before
const Button = ({ onClick }) => (
<button onClick={onClick}>Send</button>
)
// After
const Button = () = (
<ChatConsumer>
{({ add }) => (
<button onClick={add}>Send</button>
)}
</ChatConsumer>
)
The Input
component also has a few name changes you'll need to address.
Originally it expected a value
and an onChange
prop. These were just
mappings to this.state.currentMessage
and the updateCurrentMessage
method on
the Chat
component respectively:
// Before
const Input = ({ value, onChange }) => (
<input type="text" value={value} onChange={onChange} />
);
// After
const Input = () => (
<ChatConsumer>
{({ currentMessage, updateCurrentMessage }) => (
<input
type="text"
value={currentMessage}
onChange={updateCurrentMessage}
/>
)}
</ChatConsumer>
);
With these changes all errors should be resolved and the application should be working properly as before!
The story so far
That wasn't too much work. I've definitely had tougher refactors. But, was it worth it? It really depends on your use case. In my opinion, constructing compound components with this strategy is almost always worth it. Especially if you do it this way the first time around.
Let's hop into src/index.js
and see what happens when we apply the same test
as the previous post, wrapping Chat.Button
in a div
element.
// src/index.js
<Chat>
<Chat.Messages />
<Chat.Input />
<div>
<Chat.Button />
</div>
</Chat>
Last time we did this, Chat.Button
stopped working due to the fact that it
wasn't receiving its onClick
This time our app still works!
Context has solved our number one problem: passing information to children no
matter where they're at in the component tree. You can nest that button in a
hundred div
s and the sucker will still work.
This provides loads of flexiblity to the end user. There is only a single
constraint being placed on them, which is that all sub-components of Chat
must be rendered beneath Chat
. A pretty fair tradeoff I'd say.
Which leads me to my final point of this post. What were to happen if you
decided not to render a sub-component, say Chat.Button
, underneath Chat
?
// Here there be errors, arrrgh!
const App = () => (
<div>
<Chat>
<Chat.Messages />
<Chat.Input />
</Chat>
<Chat.Button />
</div>
);
Yes, an ugly little error! This is a use case we haven't planned for, and the chances of this happening in the wild are quite high, especially if you're working with open source software.
Validating consumers
This is a nifty trick I picked up from the Advanced React Patterns course given by Kent C Dodds.
Let's talk about the createContext
method real quick. createContext
can take
an optional argument, defaultValue
:
const Context = createContext(defaultValue);
defaultValue
comes in to play when a Consumer
is rendered outside of a
matching Provider
.
// a quick example
const { Provider, Consumer } = createContext('red');
const Blue = () => (
<Provider value="blue">
<Consumer>{(color) => <h1>{color}</h1>}</Consumer>
</Provider>
);
const Red = () => <Consumer>{(color) => <h1>{color}</h1>}</Consumer>;
This is great and all, but a default value is not very helpful in the case of compound components. So what else can we do?
One way to prevent users of Chat
from rendering sub-components in the wrong
place is to warn them when they're doing so. This can be done by updating
the ChatConsumer
to throw an error if no context is found.
// src/components/Chat.js
export const ChatConsumer = ({ children }) => (
<ChatContext.Consumer>
{(context) => {
if (!context) {
throw new Error('You do bad thing here!');
}
return children(context);
}}
</ChatContext.Consumer>
);
ChatConsumer
can continue to be used like normal, except now it will throw if
it's rendered out of place. Much more helpful to our users don't you think? To
be even more helpful you may want to craft a more appropriate error message.
Something like, Compound components of Chat should render beneath Chat
.
Conclusion
Hopefully this example has given you a better understanding of how compound components can work with the Context API. Possibilities abound!
Reach out to me on Twitter if you have any questions related to this post, or if you just want to talk shop! I would also love to know your thoughts on these walkthrough-style blog posts. Happy coding!