How can I make React Portal work with React Hook?

I have this specific need to listen to a custom event in the browser and from there, I have a button that will open a popup window. I’m currently using React Portal to open this other window (PopupWindow), but when I use hooks inside it doesn’t work – but works if I use classes. By working I mean, when the window opens, both shows the div below it but the one with hooks erases it when the data from the event refreshes. To test, leave the window open for at least 5 seconds.

I have an example in a CodeSandbox, but I’m also post here in case the website is down or something:

https://codesandbox.io/s/k20poxz2j7

The code below won’t run because I don’t know how to make react hooks work via react cdn but you can test it with the link above by now

const { useState, useEffect } = React;
function getRandom(min, max) {
  const first = Math.ceil(min)
  const last = Math.floor(max)
  return Math.floor(Math.random() * (last - first + 1)) + first
}
function replaceWithRandom(someData) {
  let newData = {}
  for (let d in someData) {
    newData[d] = getRandom(someData[d], someData[d] + 500)
  }
  return newData
}

const PopupWindowWithHooks = props => {
  const containerEl = document.createElement('div')
  let externalWindow = null

  useEffect(
    () => {
      externalWindow = window.open(
        '',
        '',
        `width=600,height=400,left=200,top=200`
      )

      externalWindow.document.body.appendChild(containerEl)
      externalWindow.addEventListener('beforeunload', () => {
        props.closePopupWindowWithHooks()
      })
      console.log('Created Popup Window')
      return function cleanup() {
        console.log('Cleaned up Popup Window')
        externalWindow.close()
        externalWindow = null
      }
    },
    // Only re-renders this component if the variable changes
    []
  )
  return ReactDOM.createPortal(props.children, containerEl)
}

class PopupWindow extends React.Component {
  containerEl = document.createElement('div')
  externalWindow = null
  componentDidMount() {
    this.externalWindow = window.open(
      '',
      '',
      `width=600,height=400,left=200,top=200`
    )
    this.externalWindow.document.body.appendChild(this.containerEl)
    this.externalWindow.addEventListener('beforeunload', () => {
      this.props.closePopupWindow()
    })
    console.log('Created Popup Window')
  }
  componentWillUnmount() {
    console.log('Cleaned up Popup Window')
    this.externalWindow.close()
  }
  render() {
    return ReactDOM.createPortal(
      this.props.children,
      this.containerEl
    )
  }
}

function App() {
  let data = {
    something: 600,
    other: 200
  }
  let [dataState, setDataState] = useState(data)
  useEffect(() => {
    let interval = setInterval(() => {
      setDataState(replaceWithRandom(dataState))
      const event = new CustomEvent('onOverlayDataUpdate', {
        detail: dataState
      })
      document.dispatchEvent(event)
    }, 5000)
    return function clear() {
      clearInterval(interval)
    }
  }, [])
  useEffect(
    function getData() {
      document.addEventListener('onOverlayDataUpdate', e => {
        setDataState(e.detail)
      })
      return function cleanup() {
        document.removeEventListener(
          'onOverlayDataUpdate',
          document
        )
      }
    },
    [dataState]
  )
  console.log(dataState)

  // State handling
  const [isPopupWindowOpen, setIsPopupWindowOpen] = useState(false)
  const [
    isPopupWindowWithHooksOpen,
    setIsPopupWindowWithHooksOpen
  ] = useState(false)
  const togglePopupWindow = () =>
    setIsPopupWindowOpen(!isPopupWindowOpen)
  const togglePopupWindowWithHooks = () =>
    setIsPopupWindowWithHooksOpen(!isPopupWindowWithHooksOpen)
  const closePopupWindow = () => setIsPopupWindowOpen(false)
  const closePopupWindowWithHooks = () =>
    setIsPopupWindowWithHooksOpen(false)

  // Side Effect
  useEffect(() =>
    window.addEventListener('beforeunload', () => {
      closePopupWindow()
      closePopupWindowWithHooks()
    })
  )
  return (
    <div>
      <button type="buton" onClick={togglePopupWindow}>
        Toggle Window
      </button>
      <button type="buton" onClick={togglePopupWindowWithHooks}>
        Toggle Window With Hooks
      </button>
      {isPopupWindowOpen && (
        <PopupWindow closePopupWindow={closePopupWindow}>
          <div>What is going on here?</div>
          <div>I should be here always!</div>
        </PopupWindow>
      )}
      {isPopupWindowWithHooksOpen && (
        <PopupWindowWithHooks
          closePopupWindowWithHooks={closePopupWindowWithHooks}
        >
          <div>What is going on here?</div>
          <div>I should be here always!</div>
        </PopupWindowWithHooks>
      )}
    </div>
  )
}

const rootElement = document.getElementById('root')
ReactDOM.render(<App />, rootElement)
<script crossorigin src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>
<div id="root"></div>

Read More:   HTML5 FileReader how to return result?

Thought id chime in with a solution that has worked very well for me which creates a portal element dynamically, with optional className and element type via props and removes said element when the component unmounts:

export const Portal = ({
  children,
  className="root-portal",
  element="div",
}) => {
  const [container] = React.useState(() => {
    const el = document.createElement(element)
    el.classList.add(className)
    return el
  })

  React.useEffect(() => {
    document.body.appendChild(container)
    return () => {
      document.body.removeChild(container)
    }
  }, [])

  return ReactDOM.createPortal(children, container)
}

const [containerEl] = useState(document.createElement('div'));

EDIT

Button onClick event, invoke first call of functional component PopupWindowWithHooks and it works as expected (create new <div>, in useEffect append <div> to popup window).

The event refresh, invoke second call of functional component PopupWindowWithHooks and line const containerEl = document.createElement('div') create new <div> again. But that (second) new <div> will never be appended to popup window, because line externalWindow.document.body.appendChild(containerEl) is in useEffect hook that would run only on mount and clean up on unmount (the second argument is an empty array []).

Finally return ReactDOM.createPortal(props.children, containerEl) create portal with second argument containerEl – new unappended <div>

With containerEl as a stateful value (useState hook), problem is solved:

const [containerEl] = useState(document.createElement('div'));

EDIT2

Code Sandbox: https://codesandbox.io/s/l5j2zp89k9

You could create a small helper hook which would create an element in the dom first:

import { useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";

const useCreatePortalInBody = () => {
    const wrapperRef = useRef(null);
    if (wrapperRef.current === null && typeof document !== 'undefined') {
        const div = document.createElement('div');
        div.setAttribute('data-body-portal', '');
        wrapperRef.current = div;
    }
    useLayoutEffect(() => {
        const wrapper = wrapperRef.current;
        if (!wrapper || typeof document === 'undefined') {
            return;
        }
        document.body.appendChild(wrapper);
        return () => {
            document.body.removeChild(wrapper);
        }
    }, [])
    return (children => wrapperRef.current && createPortal(children, wrapperRef.current);
}

And your component could look like this:

const Demo = () => {
    const createBodyPortal = useCreatePortalInBody();
    return createBodyPortal(
        <div style={{position: 'fixed', top: 0, left: 0}}>
            In body
        </div>
    );
}

Please note that this solution would not render anything during server side rendering.

The chosen/popular answer is close, but it needlessly creates unused DOM elements on every render. The useState hook can be supplied a function to make sure the initial value is only created once:

const [containerEl] = useState(() => document.createElement('div'));

const Portal = ({ children }) => {
  const [modalContainer] = useState(document.createElement('div'));
  useEffect(() => {
    // Find the root element in your DOM
    let modalRoot = document.getElementById('modal-root');
    // If there is no root then create one
    if (!modalRoot) {
      const tempEl = document.createElement('div');
      tempEl.id = 'modal-root';
      document.body.append(tempEl);
      modalRoot = tempEl;
    }
    // Append modal container to root
    modalRoot.appendChild(modalContainer);
    return function cleanup() {
      // On cleanup remove the modal container
      modalRoot.removeChild(modalContainer);
    };
  }, []); // <- The empty array tells react to apply the effect on mount/unmount

  return ReactDOM.createPortal(children, modalContainer);
};

Then use the Portal with your modal/popup:

const App = () => (
  <Portal>
    <MyModal />
  </Portal>
)

If you are working with Next.js, you’ll notice that many solutions don’t work because of element selectors using the document or window objects. Those are only available within useEffect hooks and such, because of server-side rendering limitations.

Read More:   Is it possible to catch exceptions thrown in a JavaScript async callback?

I’ve created this solution for myself to deal with Next.js and ReactDOM.createPortal functionality without breaking anything.

Some known issues that others can fix if they like:

  1. I don’t like having to create and append an element to the documentElement (could or should be document?) and also creating an empty container for the modal content. I feel this can be shrunk down quite a bit. I tried but it became spaghetti-code due to the nature of SSR and Next.js.
  2. The content (even if you use multiple <Portal> elements) is always added to your page, but not during server-side rendering. This means that Google and other search engines can still index your content, as long as they wait for the JavaScript to finish doing its job client-side. It would be great if someone can fix this to also render server-side so that the initial page load gives the visitor the full content.

React Hooks and Next.js Portal component

/**
 * Create a React Portal to contain the child elements outside of your current
 * component's context.
 * @param visible {boolean} - Whether the Portal is visible or not. This merely changes the container's styling.
 * @param containerId {string} - The ID attribute used for the Portal container. Change to support multiple Portals.
 * @param children {JSX.Element} - A child or list of children to render in the document.
 * @return {React.ReactPortal|null}
 * @constructor
 */
const Portal = ({ visible = false, containerId = 'modal-root', children }) => {
  const [modalContainer, setModalContainer] = useState();

  /**
   * Create the modal container element that we'll put the children in.
   * Also make sure the documentElement has the modal root element inserted
   * so that we do not have to manually insert it into our HTML.
   */
  useEffect(() => {
    const modalRoot = document.getElementById(containerId);
    setModalContainer(document.createElement('div'));

    if (!modalRoot) {
      const containerDiv = document.createElement('div');
      containerDiv.id = containerId;
      document.documentElement.appendChild(containerDiv);
    }
  }, [containerId]);

  /**
   * If both the modal root and container elements are present we want to
   * insert the container into the root.
   */
  useEffect(() => {
    const modalRoot = document.getElementById(containerId);

    if (modalRoot && modalContainer) {
      modalRoot.appendChild(modalContainer);
    }

    /**
     * On cleanup we remove the container from the root element.
     */
    return function cleanup() {
      if (modalContainer) {
        modalRoot.removeChild(modalContainer);
      }
    };
  }, [containerId, modalContainer]);

  /**
   * To prevent the non-visible elements from taking up space on the bottom of
   * the documentElement, we want to use CSS to hide them until we need them.
   */
  useEffect(() => {
    if (modalContainer) {
      modalContainer.style.position = visible ? 'unset' : 'absolute';
      modalContainer.style.height = visible ? 'auto' : '0px';
      modalContainer.style.overflow = visible ? 'auto' : 'hidden';
    }
  }, [modalContainer, visible]);

  /**
   * Make sure the modal container is there before we insert any of the
   * Portal contents into the document.
   */
  if (!modalContainer) {
    return null;
  }

  /**
   * Append the children of the Portal component to the modal container.
   * The modal container already exists in the modal root.
   */
  return ReactDOM.createPortal(children, modalContainer);
};

How to use:

const YourPage = () => {
  const [isVisible, setIsVisible] = useState(false);
  return (
    <section>
      <h1>My page</h1>

      <button onClick={() => setIsVisible(!isVisible)}>Toggle!</button>

      <Portal visible={isVisible}>
        <h2>Your content</h2>
        <p>Comes here</p>
      </Portal>
    </section>
  );
}

The issue is: a new div is created on every render, just create the div outside render
function and it should work as expected,

const containerEl = document.createElement('div')
const PopupWindowWithHooks = props => {
   let externalWindow = null
   ... rest of your code ...

https://codesandbox.io/s/q9k8q903z6

Read More:   Creating a (ES6) promise without starting to resolve it

You could also just use react-useportal. It works like:

import usePortal from 'react-useportal'

const App = () => {
  const { openPortal, closePortal, isOpen, Portal } = usePortal()
  return (
    <>
      <button onClick={openPortal}>
        Open Portal
      </button>
      {isOpen && (
        <Portal>
          <p>
            This is more advanced Portal. It handles its own state.{' '}
            <button onClick={closePortal}>Close me!</button>, hit ESC or
            click outside of me.
          </p>
        </Portal>
      )}
    </>
  )
}


The answers/resolutions are collected from stackoverflow, are licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0 .

Similar Posts