So you’ve probably noticed the recent rise of headless components — i.e. components that facilitate the reuse of control logic by delegating their presentation to a render prop.
There’s a good reason that headless components have become so popular - they’re super practical. And context makes them even better, because it solves one weakness that can make you pull your hair out…
#Consider <Link>
If you don’t mind feeling frustrated for a very short moment, take a look at this headless <Link>
component. Its render function’s props
object provides all of the state that you need to render the <a>
tag. But it still leaves you with the task of picking and placing that state onto the <a>
tag, as if you were some kind of factory robot.
<Link href="/browse" render={props =>
<a {???} style={getStyle(props.active)}>
Browse
</a>
}>
Of course, there are ways of getting around this. One common approach is to provide an aProps
prop, which can be spread onto the <a>
tag:
<Link href="/browse" render={props =>
<a {...props.aProps} style={getStyle(props.active)}>
Browse
</a>
}>
But this has it’s own issues:
- This pattern works when your headless component should render a single
<a>
tag, but what if your render function should render multiple<a>
tags? Things rapidly get confusing. - What if
aProps
contains astyle
prop? The render function would need to manually merge it in, creating a lot of work, not to mention opening the possibility of buggy code that fails to do so. - The
aProps
object will certainly have some events handlers — but which event handlers? Not knowing means that merging any of your own handlers in will be a PITA.
And that’s all before I mention the most problematic issue of all:
- It doesn’t truly separate the concerns. The render function shouldn’t need to know that the
<Link>
control requires an<a>
tag with specific props!
#True separation of concerns
In a happy dreamy world, the render()
function would have access to some sort of <Anchor>
component that can be used without knowledge of its internals. This <Anchor>
component would know how to merge in any styles, handlers, etc. And it might look something like this:
<Link href="/browse" render={props =>
<Link.Anchor style={getStyle(props.active)}>
Browse
</Link.Anchor>
}>
In fact, this API is not only possible (thanks to context) — it’s also already available in a package that I’ll be announcing next week(ish). If you’d like to try it, join Frontend Armory to stay in the loop! But it seems that I’ve gone on a bit of a tangent… so let’s get back on topic and take a look at how you’d build this <Link>
component by yourself.
#Compound headless components, with React Context
To implement this design, you’re first going to need some components:
- You’ll (obviously) need a
<Link>
component. - You’ll also need a
<Link.Anchor>
component. I’d usually call this component<LinkAnchor>
, and then just assign it to astatic Anchor
property on the<Link>
component. - Finally, you’ll need a Context object to pass data between the
<Link>
and<LinkAnchor>
components.
Let’s go through these in more detail, starting with the Context object.
#1. Create a context
Creating a context is simple. Just call React.createContext().
const LinkContext = React.createContext({
href: '',
onClick: () => {},
})
The object I’ve passed into createContext
contains the default value that Consumers will use if no Provider is available. Of course, this should never happen for a <Link>
component. But nonetheless, it’s a great way to document the context’s expected value.
#2. Create a child component
The <LinkAnchor>
component has two jobs. First, it needs to pull its parent <Link>
component’s href
and onClick
out of context. Then, it needs to merge that state with any props passed in from the render function itself, and apply them to the <a>
tag.
Here’s what this might look like in practice:
This component would probably look pettier with React’s proposed useContext() hook.
If you’re looking for a way to try hooks, try refactoring the <LinkAnchor>
component in the live editor at the bottom of this page. The editor uses an alpha version of React, so hooks will work — just don’t try this in production!
const LinkAnchor = props => (
<LinkContext.Consumer>
{linkContext =>
<a
// Spread any props passed to `<Link.Anchor>` onto the `<a>`
{...props}
// Set the `href` from context
href={linkContext.href}
// Call *both* onClick handlers, if they exist
onClick={event => {
if (props.onClick) {
props.onClick(event)
if (!event.defaultPrevented) {
linkContext.onClick(event)
}
}
}}
/>
}
</LinkContext.Consumer>
)
You might have noticed the long-winded onClick
handler in the above example — what’s with that? The comment gives you a clue: if props.onClick
and linkContext.onClick
both exist, then they both need to be called — unless the first caller calls event.preventDefault()
. And that’s why I only call linkContext.onClick
if event.defaultPrevented
isn’t true.
#3. Tie everything together
Now that you have a <LinkAnchor>
component to render the actual <a>
tag, all that <Link>
needs to do is call the render function, and set up the context so that <LinkAnchor>
has access to the correct state.
export class Link extends React.Component {
// Exporting `LinkAnchor` as a static variable on `<Link>` makes it clear
// that `LinkAnchor` is only meant to be used in conjunction with `<Link>`.
static Anchor = LinkAnchor
render() {
// This should contain any state that is needed to render the actual
// `<a>` tag.
let linkContext = {
href: this.props.href,
onClick: this.onClick,
}
// This should contain any props that the render function needs to
// handle presentation.
let rendererProps = {
active: this.props.href === window.location.pathname
}
// The `<LinkContext.Provider>` passes `linkContext` to the
// `<LinkContext.Consumer>` that is used in `<Link.Anchor>`.
return (
<LinkContext.Provider value={linkContext}>
{this.props.render(rendererProps)}
</LinkContext.Provider>
)
}
onClick = (event) => {
window.location = this.props.href
}
}
Simple, huh? In fact, this example is a little too simple; other than the active
boolean that is passed to the render function, this <Link>
component doesn’t really provide any extra features compared to a plain old <a>
tag. But despite the simplicity, there’s something really cool going on.
As it happens, the <Link>
component that drives Frontend Armory has an identical API to this component. Of course, Frontend Armory’s <Link>
has many more features — but any render function that you write for the simple example above will also work for the full featured <Link>
.
And that’s the beauty of context and headless components - they make separating presentation from logic that much easier.
#A real-world example
To finish off, it often helps to see how concepts are used in the real world. So here’s a live editor with the full featured <Link>
component that drives Frontend Armory — in all of its not-cleaned-up-for-publication glory.
Just like the above example, this <Link>
is a headless component that passes state to a <Link.Anchor>
component via context. But unlike the above component, it has a default render
prop that allows it to be used as a plain old <a>
tag — which makes it perfect for use with MDX.
This might be a little easier to read if you put the editor into fullscreen with the button at its top right.
///Link.js
import React from 'react'
import * as Navi from 'navi'
import { NavConsumer } from 'react-navi'
export const LinkContext = React.createContext()
export const LinkAnchor = props => (
<LinkContext.Consumer>
{context => {
let linkURL = context.url
let handleClick = context.handleClick
if (props.onClick) {
handleClick = (event) => {
props.onClick(event)
if (!event.defaultPrevented) {
context.handleClick(event)
}
}
}
return (
<a
id={context.id}
lang={context.lang}
ref={context.anchorRef}
rel={context.rel}
tabIndex={context.tabIndex}
target={context.target}
title={context.title}
{...props}
href={linkURL ? linkURL.href : context.href}
onClick={handleClick}
/>
)
}}
</LinkContext.Consumer>
)
export const Link = React.forwardRef((props, anchorRef) =>
<NavConsumer>
{context => <InnerLink {...props} context={context} anchorRef={anchorRef} />}
</NavConsumer>
)
Link.Anchor = LinkAnchor
Link.defaultProps = {
render: (props) => {
let {
active,
activeClassName,
activeStyle,
children,
className,
hidden,
style,
} = props
return (
<LinkAnchor
children={children}
className={`${className || ''} ${(active && activeClassName) || ''}`}
hidden={hidden}
style={Object.assign({}, style, active ? activeStyle : {})}
/>
)
}
}
class InnerLink extends React.Component {
constructor(props) {
super(props)
let url = this.getURL()
if (url && url.pathname) {
this.props.context.router.resolve(url, {
withContent: !!props.precache,
followRedirects: true,
})
.catch(() => {
console.warn(
`A <Link> referred to href "${url.pathname}", but the ` +
`router could not find this path.`
)
})
}
}
getURL() {
let href = this.props.href
// If this is an external link, return undefined so that the native
// response will be used.
if (!href || typeof href === 'string' && (href.indexOf('://') !== -1 || href.indexOf('mailto:') === 0)) {
return
}
return Navi.createURLDescriptor(href)
}
render() {
let props = this.props
let linkURL = this.getURL()
let navigationURL = this.props.context.url
let active = props.active !== undefined ? props.active : !!(
linkURL &&
(props.exact
? linkURL.pathname === navigationURL.pathname
: navigationURL.pathname.indexOf(linkURL.pathname) === 0)
)
let context = {
url: linkURL,
handleClick: this.handleClick,
...props,
href: typeof props.href === 'string' ? props.href : linkURL.href
}
return (
<LinkContext.Provider value={context}>
{props.render({
active,
activeClassName: props.activeClassName,
activeStyle: props.activeStyle,
children: props.children,
className: props.className,
disabled: props.disabled,
tabIndex: props.tabIndex,
hidden: props.hidden,
href: linkURL ? linkURL.href : props.href,
id: props.id,
lang: props.lang,
style: props.style,
target: props.target,
title: props.title,
onClick: this.handleClick,
})}
</LinkContext.Provider>
)
}
handleClick = (event) => {
// Let the browser handle the event directly if:
// - The user used the middle/right mouse button
// - The user was holding a modifier key
// - A `target` property is set (which may cause the browser to open the
// link in another tab)
if (event.button === 0 &&
!(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) &&
!this.props.target) {
if (this.props.disabled) {
event.preventDefault()
return
}
if (this.props.onClick) {
this.props.onClick(event)
}
let url = this.getURL()
if (!event.defaultPrevented && url) {
event.preventDefault()
let currentURL = this.props.context.url
let isSamePathname = url.pathname === currentURL.pathname
if (!isSamePathname || url.hash !== currentURL.hash) {
this.props.context.history.push(url)
}
else {
// Don't keep pushing the same URL onto the history.
this.props.context.history.replace(url)
}
}
}
}
}
///App.js
import React from 'react'
import { NavProvider, NavNotFoundBoundary, NavRoute } from 'react-navi'
import { Link } from './Link.js'
const LinkWithHighlight = (props) =>
<Link {...props} exact render={({ active, children }) =>
<Link.Anchor style={{ color: active ? 'red' : 'black' }}>
{children}
</Link.Anchor>
} />
export function App(props) {
return (
<NavProvider navigation={props.navigation}>
<nav>
<LinkWithHighlight href='/'>Home</LinkWithHighlight>
-
<LinkWithHighlight href='/browse'>Browse</LinkWithHighlight>
-
<LinkWithHighlight href='/members'>Members</LinkWithHighlight>
-
<LinkWithHighlight href='/404'>404</LinkWithHighlight>
</nav>
<hr />
<NavNotFoundBoundary render={() => <h1>404</h1>}>
<NavRoute />
</NavNotFoundBoundary>
</NavProvider>
)
}
///index.js
import * as Navi from 'navi'
import React from 'react'
import ReactDOM from 'react-dom'
import { App } from './App.js'
const pages = Navi.createSwitch({
paths: {
'/': Navi.createPage({
title: 'Home',
importMDX: () =>
<>
<h1>Welcome</h1>
<p>Isn't context wonderful?</p>
</>
}),
'/browse': Navi.createPage({
title: 'Browse',
importMDX: () =>
<>
<h1>Browse</h1>
<p>Through all of this example's routes within main.js</p>
</>
}),
'/members': Navi.createPage({
title: 'Members',
importMDX: () =>
<>
<h1>Frontend Armory Members</h1>
<p>Will get a bunch of new content on routing in November. If you're not already a member, sign up at the bottom of this page!</p>
</>
}),
}
})
async function main() {
const navigation = Navi.createBrowserNavigation({ pages })
await navigation.getSteadyValue();
ReactDOM.render(
<App navigation={navigation} />,
document.getElementById('root')
)
}
main()
There’s a lot going on in this <Link>
that is out of the scope of this lesson. So if you’re interested in hearing more about routing and context, create a free account to get the monthly newsletter and stay in the loop!
Thanks so much for reading — I hope it’s been helpful! If you have any questions or comments, or just want to discuss routing, get in touch by tweeting at @james_k_nelson, or sending an e-mail to james@frontarm.com.
Finally, I want to say thank you to Adam Rackis, whose tweet triggered a discussion on how to pass state to render props, and to Dan Abramov, who suggested the new context API as a solution. I had another (messier) way of accomplishing this before context arrived, but context really is the perfect way to do it.