How can I make React Portal work with React Hook?
Asked Answered
P

9

26

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>
Psalter answered 3/12, 2018 at 14:34 Comment(0)
A
12

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

Amathist answered 5/12, 2018 at 10:52 Comment(6)
While this code snippet may be the solution, including an explanation really helps to improve the quality of your post. Remember that you are answering the question for readers in the future, and those people might not know the reasons for your code suggestion.Reduction
The thing about the code is that if you leave the popup open the content is never erased with PopupWindow. With PopupWindowWithHooks it gets deleted. My question was about trying to make the portal work with hooks. Even putting the div in useState it still doesn't work. Maybe putting it inside useEffect would work. I'm goin to try that since your explanation on why the div is never recreated makes some sense. EDIT: Nah, even inside the useEffect the content still gets deleted. Maybe using useRef on containerEl to make it persist? EDIT2: Doesn't work with useRef() as well.Recor
Thank you, that solved it. I don't know why it wasn't working before but I suspect my setInterval function is messing more than helping.Recor
This isn't a great use of useState and it's unnecessarily creating DOM elements. I'd use useRef and, if the ref's current is falsy, set it to a new DOM element. And also useLayoutEffect to append and remove it. This could all be encapsulated inside a new hook that specifically creates an element ready to be rendered into immediately.Intension
You should probably use const element = useRef(document.createElement('div'))Mycostatin
You might find this useful jayfreestone.com/writing/react-portals-with-hooksDurkee
U
28

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

Ululate answered 3/12, 2019 at 10:22 Comment(3)
I've completely forgotten that you can use lazy initial state. I was using useMemo which I think is less appropriate.Lockman
@Lockman Isn't useMemo pretty much the same thing as const [result] = React.useState(() => makeResult())Broad
Is Portal a hook, or a component? if its a component, what is the benefit of this technique and whats the point of auto-generating a container - you could just use a static container in the index.html?Broad
A
12

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

Amathist answered 5/12, 2018 at 10:52 Comment(6)
While this code snippet may be the solution, including an explanation really helps to improve the quality of your post. Remember that you are answering the question for readers in the future, and those people might not know the reasons for your code suggestion.Reduction
The thing about the code is that if you leave the popup open the content is never erased with PopupWindow. With PopupWindowWithHooks it gets deleted. My question was about trying to make the portal work with hooks. Even putting the div in useState it still doesn't work. Maybe putting it inside useEffect would work. I'm goin to try that since your explanation on why the div is never recreated makes some sense. EDIT: Nah, even inside the useEffect the content still gets deleted. Maybe using useRef on containerEl to make it persist? EDIT2: Doesn't work with useRef() as well.Recor
Thank you, that solved it. I don't know why it wasn't working before but I suspect my setInterval function is messing more than helping.Recor
This isn't a great use of useState and it's unnecessarily creating DOM elements. I'd use useRef and, if the ref's current is falsy, set it to a new DOM element. And also useLayoutEffect to append and remove it. This could all be encapsulated inside a new hook that specifically creates an element ready to be rendered into immediately.Intension
You should probably use const element = useRef(document.createElement('div'))Mycostatin
You might find this useful jayfreestone.com/writing/react-portals-with-hooksDurkee
M
9

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.

Mycostatin answered 15/3, 2019 at 10:23 Comment(0)
S
5

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'));
Schaller answered 30/4, 2020 at 23:21 Comment(1)
You are right! This behavior can be verified with this code: const whatever = useState(console.log('useState')); const [count, setCount] = useState(0); setTimeout(() => setCount(count + 1), 1000); console.log('render', whatever);Glanville
E
3
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>
)
Eisler answered 13/3, 2019 at 12:47 Comment(0)
A
1

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.

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>
  );
}
Armagnac answered 23/7, 2021 at 14:2 Comment(0)
G
0

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

Gear answered 10/12, 2018 at 14:32 Comment(0)
L
0

React Portal with useRef and custom tag name:

import {ReactNode, useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';

export type TPortal = {children: ReactNode; tagName: string};

export const Portal = ({tagName, children}: TPortal) => {
  const ref = useRef(document.createElement(tagName + '-portal'));

  useEffect(() => {
    document.body.appendChild(ref.current);
    return () => {
      document.body.removeChild(ref.current);
    };
  }, []);

  return createPortal(children, ref.current);
};
Lysis answered 6/4, 2023 at 0:0 Comment(0)
O
-3

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>
      )}
    </>
  )
}
Overflow answered 18/4, 2019 at 3:19 Comment(1)
Probably should mention you're the authorTennyson

© 2022 - 2024 — McMap. All rights reserved.