DEV Community

loading...

Sharing Context with React Portals

mickmister profile image Michael Kochell Updated on ・2 min read

State management in React is a widely discussed topic on the internet. While the most common approach for large applications seems to be Redux, there has been a lot of attention on the improvements of the React Context API. Using render props, we can now access state being held far up the tree with ease. For a single page application, this is ideal because the whole application is one tree.

However, if you are mounting several trees of components throughout the page (in my case I'm using Ruby on Rails and Webpacker React to render the components), the different trees cannot use the same provider as a common ancestor. Hence, they cannot share the same state. So how do we get the different trees to play nicely together?

React Context + React Portals

Using a syntax very similar to:

ReactDOM.render(<MyComponent />, domElement);

We can use

const PortalComponent = React.createPortal(<MyComponent />, domElement);

to render multiple components in the same React tree, among different isolated parts of the DOM. Using React.createPortal() we can create a new React component to be rendered at the specified DOM node.

What's different about createPortal() when compared to ReactDOM.render(), is that the component is not rendered at the point of calling createPortal(). We use the return value of React.createPortal() (a new React component), and render it in our tree. Here's an example where we are rendering components with different entry points throughout the page:

/* first let's define a context to share */
/* important_value_context.jsx */

import React from 'react'

const Context = React.createContext({})

export class ImportantValueProvider extends React.PureComponent {

  state = {
    importantValue: 'Cake is good',
    updateImportantValue: this.updateImportantValue,
  }

  updateImportantValue = importantValue => this.setState({importantValue})

  render() {
    return (
      <Context.Provider value={this.state}>
        {this.props.children}
      </Context.Provider>
    )
  }
}

export default Context


/* main.jsx */

import React from 'react'
import ReactDOM from 'react-dom'

import {ImportantValueProvider} from 'path/to/important_value_context'
import CoolComponent from 'path/to/cool_component'
import SuperCoolComponent from 'path/to/super_cool_component'

const Main = () => {

  const ComponentA = () => ReactDOM.createPortal(
    <CoolComponent />,
    document.getElementById('banner'),
  )

  const ComponentB = () => ReactDOM.createPortal(
    <SuperCoolComponent />,
    document.getElementById('footer'),
  )

  return (
    <ImportantValueProvider>
      <ComponentA />
      <ComponentB />
    </ImportantValueProvider>
  )
}

ReactDOM.render(
  <Main />,
  document.getElementById('main'),
)

Instead of making two calls to ReactDOM.render(), we create two portals and render both under our top-level Provider. ComponentA and ComponentB will be rendered in two different points in the DOM, but they share the same React tree, thanks to portals. They can both import ImportantValueContext and reference its consumer to communicate with the same instance of ImportantValueContext.

/* cool_component.jsx */

import React from 'react'

import ImportantValueContext from 'path/to/important_value_context'

class CoolComponent extends React.PureComponent {
  render() {
    return (
      <ImportantValueContext.Consumer>
        {sharedState => (
          <div>
            <input
              value={sharedState.importantValue}
              onChange={e => sharedState.updateImportantValue(e.target.value)}
            />
          </div>
        )}
      </ImportantValueContext.Consumer>
    )
  }
}

export default CoolComponent


/* super_cool_component.jsx */

import React from 'react'

import ImportantValueContext from 'path/to/important_value_context'

class SuperCoolComponent extends React.PureComponent {
  render() {
    return (
      <ImportantValueContext.Consumer>
        {sharedState => (
          <h1>{sharedState.importantValue}</h1>
        )}
      </ImportantValueContext.Consumer>
    )
  }
}

export default SuperCoolComponent

Now CoolComponent and SuperCoolComponent can share state with the same ImportantValueProvider. That's fantastic news if you plan on sprinkling components into an existing webpage... if you're into that sort of thing.

NOTE: This applies to working with Redux providers as well, though using context was my use case so I put emphasis on that instead.

Discussion (4)

pic
Editor guide
Collapse
bmmpt profile image
Bruno Mendonca • Edited

What about?

  return (
    <div>
      <ImportantValueProvider>
        <ComponentA />
      </ImportantValueProvider>
      <Modal>
        <ComponentB />
      </Modal>
  )

In this case they are in different trees and cant share context or can they?

Collapse
mickmister profile image
Michael Kochell Author

Libraries that export a Modal component are usually using a Portal under the hood. In this case, you do need that <Modal> inside of the <ImportantValueProvider>. There's no way around it.

Your Provider should be at the top of your application, unless you have multiple instances of the same kind of Provider, which is sort of an anti-pattern unless you have a niche use case that fits it. In short, put your Provider at the top-level of your app and you should be good to go.

Collapse
ghost profile image
Ghost

Hi Michael, thanks for share, I'm doing an idea and your tutorial helped me a lot.

But implementing I had one problem, I don't know if it was because of the version of react or react-dom, to fix, I needed to change one piece of the code in file main.jsx

From:

/* main.jsx */

const Main = () => {
  const ComponentA = ReactDOM.createPortal(
    <CoolComponent />,
    document.getElementById('banner'),
  );

  const ComponentB = ReactDOM.createPortal(
    <SuperCoolComponent />,
    document.getElementById('footer'),
  );

  return (
    <ImportantValueProvider>
      <ComponentA />
      <ComponentB />
    </ImportantValueProvider>
  );
};

For:

/* main.jsx */

function Main() {
  function ComponentA() {
    return ReactDOM.createPortal(
      <CoolComponent />,
      document.getElementById('banner'),
    );
  }

  function ComponentB() {
    return ReactDOM.createPortal(
      <SuperCoolComponent />,
      document.getElementById('footer'),
    );
  }

  return (
    <ImportantValueProvider>
      <ComponentA />
      <ComponentB />
    </ImportantValueProvider>
  );
}

For some reason, when I use constants instead of functions in my setup (react and react-dom: 16.8.6) , react throw this error:

Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.

Check the render method of `Main`.

If someone else get the same error/problem, the solution above may will work =]

Cheers o/

Collapse
mickmister profile image
Michael Kochell Author • Edited

You're right, never trust the internet!

Thanks for the kind words!

Edit: Added the functions so it works now