38

Passing state to render props via React Context

 5 years ago
source link: https://www.tuicool.com/articles/hit/rEz67vU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 a style 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 a static 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

App.js

main.js

index.html

New

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) =>
  <ReactNavi.Consumer>
    {context => <InnerLink {...props} context={context} anchorRef={anchorRef} />}
  </ReactNavi.Consumer>
)

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)
        }
      }
    }
  }
}

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 [email protected] .

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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK