Creating a configurable component library with React Context

Matt Miller
14 min readJan 11, 2019

--

You’re in charge of designing and developing the component library at your company. You may extend a popular open source package like Bootstrap or Material Design, or maybe you’re starting from scratch.

You design your components to be flexible, and you’re happy with the configurability. You export buttons, panels, modals, form controls, and nav bars. Each one comes with its own variants: theme, size, color, and so on. You’re satisfied with the API of your components.

The problem

Then you see your components put to use:

import React from 'react'
import { render } from 'react-dom'
import { Panel, Field, Input } from 'my-ui'
render(
<Panel theme="dark">
<form>
<Field label="First Name" theme="dark">
<Input type="text" theme="dark" />
</Field>
<Field label="Last Name" theme="dark">
<Input type="text" theme="dark" />
</Field>
</form>
</Panel>,
document.querySelector('.app')
)

The consumer of your library is getting tired of repeating theme="dark" because the entire app is dark.

This is a pretty common issue when using a React component library, because each component can be rendered in isolation. Most libraries are not set up to be provided “global configuration,” which makes sense. However, this can become a hassle.

Your library’s user has a few debatably effective workarounds.

They can wrap each UI component in another component which passes the prop(s) they always want to use:

export const DarkPanel = props => <Panel theme="dark" {...props} />
export const DarkField = props => <Field theme="dark" {...props} />
export const DarkInput = props => <Input theme="dark" {...props} />

This is only marginally less redundant. Since they might want to apply this functionality to all components in the library, they may attempt to bind them all programmatically:

import * as components from 'my-ui'const darkComponents = Object.keys(components).reduce(
(boundComponents, componentName) => {
const Component = components[componentName]
const BoundComponent = props => (
<Component theme="dark" {...props} />
)
boundComponents[componentName] = BoundComponent
return boundComponents
},
{}
)
export default darkComponents

The major drawback to this approach is that tree-shaking no longer becomes possible, as all the components in the library are imported into the consuming app. It also may require additional hacky type-checking if your package exports other modules besides just React components. And, of course, depending on the consistency of your API, some components may not use the theme prop at all or may accept a different prop type for it.

What if you had a more React-like approach to allow the user to configure all components?

Using React Context

With the new React Context API, it makes more sense to export a Provider component that can be used to set common configuration across components rendered in your app. All with a familiar API and, of course, opt-in.

Let’s look at how we could export a Provider:

import { createContext } from 'react'const MyUiContext = createContext({})export default MyUiContext

First, we’ll look at how this provider can be used by the app. Then we will examine how to consume the context values in our UI components.

import React from 'react'
import { render } from 'react-dom'
import {
MyUiContext,
Panel,
Field,
Input
} from 'my-ui'
render(
<MyUiContext.Provider value={{ theme: 'dark' }}>
<Panel>
<form>
<Field label="First Name">
<Input type="text" />
</Field>
<Field label="Last Name">
<Input type="text" />
</Field>
</form>
</Panel>
</MyUiContext.Provider>,
document.querySelector('.app')
)

The provider allows the user to set some configuration that all of the UI components that are nested beneath it can access.

Now it’s a matter of consuming the context value. Let’s examine what one of our components might look like right now:

import React from 'react'const Panel = ({ theme, size, children }) => (
<div className={`panel theme-${theme} size-${size}`}>
{children}
</div>
)
Panel.defaultProps = {
theme: 'light',
size: 'medium'
}
export default Panel

Now, let’s import our context’s Consumer component and use it to read the context:

import React from 'react'
import Context from '../Context'
const Panel = ({ theme, size, children }) => (
<Context.Consumer>
{ctx => (
<div
className={[
'panel',
`theme-${theme || ctx.theme || 'light'}`,
`size-${size}`
].join(' ')}
>
{children}
</div>
)}
</Context.Consumer>
)
Panel.defaultProps = {
size: 'medium'
}
export default Panel

Now our component consumes the context, and its value is passed into the arrow function. We read the value from the context for the theme, unless a theme prop was passed to the component, which takes higher priority. (Note that we had to remove the defaultProps value, because otherwise theme would never be falsy.)

What’s pretty neat about this is that this component will actually render even if it is not embedded in a Provider, making this a backwards-compatible and opt-in API change. If the user does not render the component in a Provider, the “default context” value is used. We passed {} to createContext, so if our component is rendered outside of a Provider, ctx will just be {}.

This is starting to feel like a lot of boilerplate for functionality that is needed in many components in your library. This is where react-connect-context-map might come in handy, which allows us to map values from the consumed context to props on the component, just like with react-redux.

Using connectContext

This factory function takes our Consumer and a mapContextToProps function which can be used to translate the value of our context into props we can feed to our component:

import React from 'react'
import { connectContext } from 'react-connect-context-map'
import Context from '../Context'
const Panel = ({ theme, size, children }) => (
<div className={`panel theme-${theme} size-${size}`}>
{children}
</div>
)
Panel.defaultProps = {
theme: 'light',
size: 'medium'
}
export default connectContext(
Context.Consumer,
context => ({ theme: context.theme })
)(Panel)

Now you can see that the original component is untouched. We created an HOC using react-connect-context-map and provided a mapContextToProps function which mapped the theme property from context. The default behavior of connectContext is to prioritize explicitly passed props over context values, so the user can still pass a theme prop to this component to override the default theme set in the Provider. In other words:

  • If the component is not rendered in a Provider, the context is {} and mapContextToProps returns { theme: undefined }.
  • If the component is rendered in a Provider that set the theme to 'dark', mapContextToProps returns { theme: 'dark' }.
  • If the component receives a theme prop passed into it, it takes precedence over the result of mapContextToProps. So it doesn’t matter what the context value is if the user renders <Panel theme="party">, it will use 'party' instead.
  • If the component does not receive a theme prop and the mapContextToProps function returned { theme: undefined }, the component is passed undefined for the theme prop, and React will use the value specified in defaultProps.

Our component stays cleanly isolated and we even get defaultProps back!

Defining functions in context

Unlike Redux state, context may contain more than just plain objects, arrays, and primitives. If some of your components render links (such as a navigation menu), you can leverage the flexibility of letting the user configure how the links are rendered (react-router’s Link component, for example) without requiring them to pass the component in as props every time:

import React from 'react'
import { connectContext } from 'react-connect-context-map'
import Context from '../Context'
const createContainer = connectContext(
Context.Consumer,
context => ({
linkComponent: context.linkComponent
})
)
const Nav = ({ linkComponent: Link, items }) => (
<ul>
{items.map((item, i) => (
<li key={i}>
<Link href={item.href}>{item.label}</Link>
</li>
)}
</ul>
)
Nav.defaultProps = {
linkComponent: 'a'
}
export default createContainer(Nav)

Then the link component can be provided once by the user:

import React from 'react'
import { render } from 'react-dom'
import { MyUiContext, Nav } from 'my-ui'
import { Link as RouterLink } from 'react-router-dom'
const Link = ({ href, ...props }) => (
/^(https?:)?\/\//.test(href)
? <a href={href} {...props} />
: <RouterLink to={href} {...props} />
)
render(
<MyUiContext.Provider value={{ linkComponent: Link }}>
<Nav
items={[
{ href: '/foo', label: 'Foo' },
{ href: 'https://www.google.com/', label: 'Google' }
]}
/>
</MyUiContext.Provider>,
document.querySelector('.app')
)

Context could also be used to set up generic event listeners that can be called in components, add a custom class name to every component, or provide internationalized messages.

Better practices

As you develop your library, try to leverage common propTypes sets and container HOCs to select and translate context values to props.

One last thing we could do to make the API more natural to the user is to replace Provider with a simpler component that receives config as props instead of the generic value object. This also lets us do some checking of the config props with propTypes if we want, and defaultProps for setting fallbacks.

import React, { createContext } from 'react'
import Context from '../Context'
const Provider = ({ theme, linkComponent, children }) => (
<Context.Provider
value={{ theme, linkComponent }}
>
{children}
</Context.Provider>
)
Provider.defaultProps = {
theme: 'light',
linkComponent: 'a'
}
export default Provider

Just remember that even though defaultProps in our Provider might be helpful for some default settings, we won’t have these defaults in our components should the user choose not to use our Provider at all.

Be careful passing around object literals for the context value. Unlike props and state, context is not shallowly compared to determine equality, it is strictly compared to the previous reference. So avoid constructing objects in the app’s render function for the Provider value, or your app may re-render unnecessarily.

To optimize this, we should use a stateful React class for our Provider that only changes the context value when a config prop changed. In other words, it will not set a new context every time the component that renders our Provider re-renders; it will only set a new context if one of the relevant config props on the Provider changed. We can do this by comparing those props:

import React, { PureComponent } from 'react'
import { omit } from 'lodash-es'
import shallowEqual from 'shallowequal'
import Context from '../Context'
class Provider extends PureComponent {
static getConfig(props) {
return omit(props, 'children')
}
constructor(props) {
super(props)
this.state = {
config: getConfig(props)
}
}
componentWillReceiveProps(nextProps) {
const currentConfig = getConfig(this.props)
const newConfig = getConfig(nextProps)
if (!shallowEqual(currentConfig, newConfig)) {
this.setState({ config: newConfig })
}
}
render() {
return (
<Context.Provider value={this.state.config}>
{this.props.children}
</Context.Provider>
)
}
}
Provider.defaultProps = {
theme: 'light',
linkComponent: 'a'
}
export default Provider

Here we are being careful to only change the context value if one of the config props passed to the provider changes. We assume all the props that are passed to our provider are config settings, except the children prop. We only replace our context value with a fresh object if one of those props changes.

I used shallowequal here for comparing the configurations, but a recursive check like lodash.isEqual would also help if your config props contain nested objects.

Advanced: Multiple configurations

In large apps, sometimes it makes sense to have multiple contexts with shared configurations. For example, a certain section of the page may need to render all child components with theme 1, while another section renders theme 2. This is especially useful when designing pages that present data visualizations, where color schemes are associated with the classification of the data being shown.

In this case, you might wonder if you can render a Provider but somewhere else in the application, render a nested Provider that provides different configuration to the components beneath it. In other words, can you set some global settings for the UI library but override those globals in specific areas of the application, without explicitly passing in all the props like we had to before we were leveraging context?

The short answer is: no. react-redux attempted to solve this problem by adding a createProvider function and using “store keys.” The idea behind it was that instead of the context value storing a single config (or in react-redux’s case, a reference to a Redux store), it would store a collection of them, each indexed under a unique, user-provided storeKey. You could create multiple Provider components bound to different store keys, and you could pass a store key to react-redux’s connect function parameters to tell a component to read a specific store instead of consuming from the “default” store.

This design doesn’t really help us — because we call connectContext in our UI library, rather than delegating the application to wire up our components to a config. In other words, we could implement the storeKey setup and keep a collection of configs by configKey — but our UI components would have no way of knowing which configKey to use.

We could get this from props — i.e. each of our UI library’s components that needs to be rendered with the custom config could take a prop configKey which could be read by our mapContextToProps function to map the correct configuration values. But now we’re back to square one: the developer using our library must pass a prop to every component to get the desired behavior — we’ve just replaced <Panel theme="dark" size="large"> with const LargeDarkProvider = createProvider('largeDark'), <LargeDarkProvider theme="dark" size="large">, and <Panel configKey="largeDark">. It’s significant added complexity that probably outweighs its benefits in most scenarios.

This is why, after the introduction of the new React Context API, react-redux dropped this pattern. Instead, to create multiple providers, we can delegate the context management to the library user.

The idea of storing collections of configurations in a single context value is troublesome. What’s better is to use multiple context instances instead. Since we don’t know how many contexts will be used, we can let the user pass in contexts for us to use instead.

Let’s start by adapting our latest Provider component design:

import React, { PureComponent } from 'react'
import { omit } from 'lodash-es'
import shallowEqual from 'shallowequal'
class Provider extends PureComponent {
static getConfig(props) {
return omit(props, 'children', 'context')
}
constructor(props) {
super(props)
this.state = {
config: getConfig(props)
}
}
componentWillReceiveProps(nextProps) {
const currentConfig = getConfig(this.props)
const newConfig = getConfig(nextProps)
if (!shallowEqual(currentConfig, newConfig)) {
this.setState({ config: newConfig })
}
}
render() {
const { children, context } = this.props
return (
<context.Provider value={this.state.config}>
{children}
</context.Provider>
)
}
}
Provider.defaultProps = {
theme: 'light',
linkComponent: 'a'
}
export default Provider

As you can see, we no longer call createContext and instead let the user pass in a context instance and let us render its Provider:

import React, { createContext } from 'react'
import { render } from 'react-dom'
import { Provider as MyUiProvider, Panel } from 'my-ui'
const MyUiContext = createContext()render(
<MyUiProvider
context={MyUiContext}
theme="light"
>
<Panel>Hello</Panel>
</MyUiProvider>,
document.querySelector('.app')
)

But how do we consume the context in our components? After all, we used to export a Consumer from our context file that we could pass to createContext.

The truth is, we can’t, not entirely. In order for our UI components to support contexts defined by the user, they need to receive the Consumer, which means we must pass it in as a prop. This isn’t ideal, as it puts us close to square one, where we either repeat a prop passed to every component or create wrappers for the components (like DarkPanel).

The best we can do is offer a utility function on our component that makes “binding” our component to different Consumers easier:

import React from 'react'
import { connectContext } from 'react-connect-context-map'
const Panel = ({ theme, size, children }) => (
<div className={`panel theme-${theme} size-${size}`}>
{children}
</div>
)
Panel.defaultProps = {
theme: 'light',
size: 'medium'
}
Panel.bindTo = Consumer => (
connectContext(
Consumer,
context => ({
theme: context && context.theme
})
)(Panel)
)
export default Panel

Now we have the ability for the user to pass in a context to bind the component to:

import React, { createContext } from 'react'
import { render } from 'react-dom'
import { Provider as MyUiProvider, Panel } from 'my-ui'
const DefaultUiContext = createContext()
const BlueUiContext = createContext()
const DefaultPanel = Panel.bindTo(DefaultUiContext.Consumer)
const BluePanel = Panel.bindTo(BlueUiContext.Consumer)
render(
<MyUiProvider context={DefaultUiContext} theme="light">
<DefaultPanel>Hello world</DefaultPanel>
<MyUiProvider context={BlueUiContext} theme="blue">
<BluePanel>Hello world</BluePanel>
</MyUiProvider>
</MyUiProvider>,
document.querySelector('.app')
)

You may ask: what’s the point? We’ve had to create a copy of the component! This is the same as DarkPanel.

This kind of implementation only adds value if the configuration is likely to change over time — for example, let’s say a button in the a section of the app allows the user to change the color scheme of that section to red. In this case, the component that renders the Provider would hold some kind of state and the value of the theme prop passed to our Provider would come from state:

import React, { PureComponent } from 'react'
import { Provider as MyUiProvider } from 'my-ui'
import {
RevenueSectionContext,
ExpenditureSectionContext
} from '../contexts'
import RevenueSection from './RevenueSection'
import ExpenditureSection from './ExpenditureSection'
class App extends PureComponent {
state = {
revenueTheme: 'green',
expenditureTheme: 'red'
}
handleChangeRevenueTheme = theme => {
this.setState({ revenueTheme: theme })
}
handleChangeExpenditureTheme = theme => {
this.setState({ expenditureTheme: theme })
}
render() {
return (
<>
<MyUiProvider
context={RevenueSectionContext}
theme={this.state.revenueTheme}
>
<RevenueSection
onThemeChange={this.handleChangeRevenueTheme}
/>
</MyUiProvider>
<MyUiProvider
context={ExpenditureSectionContext}
theme={this.state.expenditureTheme}
>
<ExpenditureSection
onThemeChange={this.handleChangeExpenditureTheme}
/>
</MyUiProvider>
</>
)
}
}

And, in one of the section components:

import React from 'react'
import { Panel } from 'my-ui'
import { RevenueSectionContext } from '../contexts'
const RevenuePanel = Panel.bindTo(RevenueSectionContext.Consumer)const RevenueSection = ({ onThemeChange }) => (
<RevenuePanel>
<h1>Hello</h1>
<button onClick={() => onThemeChange('blue')}>
Change to blue
</button>
</RevenuePanel>
)
export default RevenueSection

As you can see, the advantage is that, with the binding to context versus just binding some constant prop values, we allow the context value to be mutable.

To help the user out, we might export a utility that binds many components at once:

const bindComponentsTo = Consumer => (...components) => (
components.map(Component => Component.bindTo(Consumer))
)
export default bindComponentsTo

And the user can reduce the repetition of binding components:

import { Panel, Field, Input, bindComponentsTo } from 'my-ui'
import { DarkContext } from '../contexts'
const [
DarkPanel,
DarkField,
DarkInput
] = bindComponentsTo(DarkContext.Consumer)(Panel, Field, Input)

And, of course, we can abstract out our bindTo function with an HOC in our library:

import { connectContext } from 'react-connect-context-map'const addBindTo = (mapContextToProps, mergeProps) => Component => {
Component.bindTo = Consumer => (
connectContext(
Consumer,
mapContextToProps,
mergeProps
)(Component)
)
return Component
}
export default addBindTo

And wrap each of our library’s components with it:

import React from 'react'
import addBindTo from '../hocs/addBindTo'
const Panel = ({ theme, size, children }) => (
<div className={`panel theme-${theme} size-${size}`}>
{children}
</div>
)
Panel.defaultProps = {
theme: 'light',
size: 'medium'
}
export default addBindTo(context => ({
theme: context.theme
})(Panel)

Let’s keep in mind that most users won’t need this behavior. So even if we do include this capability in our library, the default setup should hide the context implementation details from the user and just use a single global context.

Very simply, this means we should still create a single context for default usage, and bind our components to it.

We’ll create a single “default” context for our library:

import { createContext } from 'react'export default createContext()

And our HOC will return the component bound to our default context (but it still exposes the bindTo method for when users want to bind to another context):

import { connectContext } from 'react-connect-context-map'
import DefaultContext from '../DefaultContext'
const addBindTo = (mapContextToProps, mergeProps) => Component => {
Component.bindTo = Consumer => (
connectContext(
Consumer,
mapContextToProps,
mergeProps
)(Component)
)
return Component.bindTo(DefaultContext.Consumer)
}

And our Provider should use it by default:

import React, { PureComponent } from 'react'
import { omit } from 'lodash-es'
import shallowEqual from 'shallowequal'
import DefaultContext from '../DefaultContext'
class Provider extends PureComponent {
static getConfig(props) {
return omit(props, 'children', 'context')
}
constructor(props) {
super(props)
this.state = {
config: getConfig(props)
}
}
componentWillReceiveProps(nextProps) {
const currentConfig = getConfig(this.props)
const newConfig = getConfig(nextProps)
if (!shallowEqual(currentConfig, newConfig)) {
this.setState({ config: newConfig })
}
}
render() {
const { children, context } = this.props
return (
<context.Provider value={this.state.config}>
{children}
</context.Provider>
)
}
}
Provider.defaultProps = {
theme: 'light',
linkComponent: 'a',
context: DefaultContext
}
export default Provider

Now the user may use a single global context by rendering children inside our Provider component without passing a context prop — or, they can use multiple contexts by passing a context prop to the provider and binding the UI components to that same context object with the utilities our library provided.

Conclusion

Context is great! And it’s not just for fancy tools like Redux anymore. It’s a powerful React feature that is especially useful for component packages that are designed to be flexible enough for use in many apps, without introducing so much prop redundancy.

See it all put together on CodeSandbox:

https://codesandbox.io/s/6wpk3lo9lr

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Matt Miller
Matt Miller

No responses yet

Write a response