Using the New Portal Feature in React

Danny Hurlburt

React v16 introduced a new feature called portals. The documentation states that:

Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.

Normally, a functional or a class component renders a tree of React elements (usually generated from JSX). The React element defines how the DOM of the parent component should look.

Prior to v16, only a few child types were allowed to be rendered:

  • null or false (means render nothing).
  • JSX.
  • React elements.
function Example(props) {
  return null;
}
function Example(props) {
  return false;
}
function Example(props) {
  return <p>Some JSX</p>;
}
function Example(props) {
  return React.createElement(
    'p',
    null,
    'Hand coded'
  );
}

In v16, more child types were made renderable:

  • Numbers (including Infinity and NaN).
  • Strings.
  • React portals.
  • An array of renderable children.

Full list of renderable children

function Example(props) {
  return 42;  // Becomes a text node.
}
function Example(props) {
  return 'The meaning of life.';  // Becomes a text node.
}
function Example(props) {
  return ReactDOM.createPortal(
    // Any valid React child type
    [
      'A string',
      <p>Some JSX</p>,
      'etc'
    ],
    props.someDomNode
  );
}

React portals are created by calling ReactDOM.createPortal. The first argument should be a renderable child. The second argument should be a reference to the DOM node where the renderable child will be rendered. ReactDOM.createPortal returns an object that is similar in nature to what React.createElement returns.

Note that createPortal is in the ReactDOM namespace and not the React namespace like createElement.

Some observant readers may have noticed that the ReactDOM.createPortal signature is the same as ReactDOM.render, which makes it easy to remember. However, unlike ReactDOM.render, ReactDOM.createPortal returns a renderable child which is used during the reconciliation process.

When to Use

React portals are very useful when a parent component has overflow: hidden declared or has properties that affect the stacking context and you need to visually “break out” of its container. Some examples include dialogs, global message notifications, hovercards, and tooltips.

Event Bubbling Through Portals

The React documentation explains this very well.

Even though a portal can be anywhere in the DOM tree, it behaves like a normal React child in every other way. Features like context work exactly the same regardless of whether the child is a portal, as the portal still exists in the React tree regardless of its position in the DOM tree.

This includes event bubbling. An event fired from inside a portal will propagate to ancestors in the containing React tree, even if those elements are not ancestors in the DOM tree.

This makes listening to events in your dialogs, hovercards, etc, as easy as if they were rendered in the same DOM tree as the parent component.

Example

In the following example, we’ll take advantage of React portals and its event bubbling feature.

The markup begins with the following.

<div class="PageHolder">
</div>
<div class="DialogHolder  is-empty">
  <div class="Backdrop"></div>
</div>
<div class="MessageHolder">
</div>

The .PageHolder div is where the main part of our application lives. The .DialogHolder div will be where any generated dialogs are rendered. The .MessageHolder div will be where any generated messages are rendered.

Because we want all dialogs to be visually above the main part of our application, the .DialogHolder div has z-index: 1 declared. This will create a new stacking context independent of .PageHolder’s stacking context.

Because we want all messages to be visually above any dialogs, the .MessageHolder div has z-index: 1 declared. This will create a sibling stacking context to the .DialogHolder’s stacking context. Although the z-index of the sibling stacking contexts have the same value, this will still render how we want due to the fact that .MessageHolder comes after .DialogHolder in the DOM tree.

The following CSS summarizes the necessary rules to establish the desired stacking context.

.PageHolder {
  /* Just use stacking context of parent element. */
  /* A z-index: 1 would still work here. */
}

.DialogHolder {
  position: fixed;
  top: 0; left: 0;
  right: 0; bottom: 0;
  z-index: 1;
}

.MessageHolder {
  position: fixed;
  top: 0; left: 0;
  width: 100%;
  z-index: 1;
}

The example will have a Page component which will be rendered into .PageHolder.

class Page extends React.Component { /* ... */ }

ReactDOM.render(
  <Page/>,
  document.querySelector('.PageHolder')
)

Because our Page component will be rendering dialogs and messages into the .DialogHolder and the .MessageHolder, respectively, it will need a reference to these holder divs at render-time. We have several options.

We could resolve the references to these holder divs before rendering the Page component and pass them as properties to the Page component.

let dialogHolder = document.querySelector('.DialogHolder');
let messageHolder = document.querySelector('.MessageHolder');

ReactDOM.render(
  <Page dialogHolder={dialogHolder} messageHolder={messageHolder}/>,
  document.querySelector('.PageHolder')
);

We could pass selectors to the Page component as properties and then resolve the references in componentWillMount for the initial render and re-resolve in componentWillReceiveProps if the selectors change.

class Page extends React.Component {

  constructor(props) {
    super(props);
    let { dialogHolder = '.DialogHolder',
          messageHolder = '.MessageHolder' } = props

    this.state = {
      dialogHolder,
      messageHolder,
    }
  }

  componentWillMount() {
    let state = this.state,
        dialogHolder = state.dialogHolder,
        messageHolder = state.messageHolder

    this._resolvePortalRoots(dialogHolder, messageHolder);
  }

  componentWillReceiveProps(nextProps) {
    let props = this.props,
        dialogHolder = nextProps.dialogHolder,
        messageHolder = nextProps.messageHolder

    if (props.dialogHolder !== dialogHolder ||
        props.messageHolder !== messageHolder
    ) {
      this._resolvePortalRoots(dialogHolder, messageHolder);
    }
  }

  _resolvePortalRoots(dialogHolder, messageHolder) {
    if (typeof dialogHolder === 'string') {
      dialogHolder = document.querySelector(dialogHolder)
    }
    if (typeof messageHolder === 'string') {
      messageHolder = document.querySelector(messageHolder)
    }
    this.setState({
      dialogHolder,
      messageHolder,
    })
  }

}

Now that we have ensured we will have DOM references for the portals, we can render the Page component with dialogs and messages.

Just like React elements, React portals are rendered based on the component properties and state. For this example, we will have two buttons. One will create dialog portals to be rendered in the dialog holder when clicked, and the other one will create message portals to be rendered in the message holder. We will keep references to these portals in the component’s state which will be used in the render method.

class Page extends React.Component {
  // ...

  constructor(props) {
    super(props);
    let { dialogHolder = '.DialogHolder',
          messageHolder = '.MessageHolder' } = props

    this.state = {
      dialogHolder,
      dialogs: [],
      messageHolder,
      messages: [],
    }
  }

  render() {
    let state = this.state,
        dialogs = state.dialogs,
        messages = state.messages

    return (
      <div className="Page">
        <button onClick={evt => this.addNewDialog()}>
          Add Dialog
        </button>
        <button onClick={evt => this.addNewMessage()}>
          Add Message
        </button>
        {dialogs}
        {messages}
      </div>
    )
  }

  addNewDialog() {
    let dialog = ReactDOM.createPortal((
        <div className="Dialog">
          ...
        </div>
      ),
      this.state.dialogHolder
    )
    this.setState({
      dialogs: this.state.dialogs.concat(dialog),
    })
  }

  addNewMessage() {
    let message = ReactDOM.createPortal((
        <div className="Message">
          ...
        </div>
      ),
      this.state.messageHolder
    )
    this.setState({
      messages: this.state.messages.concat(message),
    })
  }


  // ...
}

To demonstrate that events will bubble from the React portal components up to the parent component, let’s add a click handler on the .Page div.

class Page extends React.Component {
  // ...

  render() {
    let state = this.state,
        dialogs = state.dialogs,
        messages = state.messages

    return (
      <div className="Page" onClick={evt => this.onPageClick(evt)}>
        ...
      </div>
    )
  }

  onPageClick(evt) {
    console.log(`${evt.target.className} was clicked!`);
  }

  // ...
}

When a dialog or a message is clicked, the onPageClick event handler will be called (as long as another handler did not stop propagation).

See a working example of the above demonstration.

👉 Use React portals when you run into overflow: hidden or stacking context issues!

  Tweet It
✖ Clear

🕵 Search Results

🔎 Searching...