kenshin
kenshin

Reputation: 215

Create React Portal via Ref

I would like to create a Portal component that is supposed to be attached to it's container component, but not via the container's ID but by it's ref. In other words, I don't want to pass document.getElementById('CONTAINER_ID') as the second argument to the ReactDOM.createPortal function but to rely solely on the ref of the container being passed by React.forwardRef. Is there a straightforward method of achieving this ? Else maybe I would need to create a dom node "attached" to the ref first and than pass the node to the createPortal function as the second argument ? I would like to avoid assigning the ids as much as possible. Here is the example code (I work in TypeScript) :

export default React.forwardRef<HTMLDivElement, Props>( (Props, ref) =>{

        const {state, options, dispatch} = Props                     
        if(!state.open) return null
    
        return  ReactDOM.createPortal(
            <div
            className={css`
              height: 140px;
              background: white;
              overflow-y: scroll;
              position: absolute;
              width:100%;
            `}
          >
            {options}
          </div>,
          ref.current // <-- THIS DOESN'T WORK
        )
        
    }    
)

Upvotes: 4

Views: 9168

Answers (3)

Conor Hinchee
Conor Hinchee

Reputation: 53

Portals require a DOM Node to attach to and if you are trying to render a portal on a different part of the React APP, it is possible that the element you are trying to attach the portal to is no longer present in the DOM.

If you can, you should 100% attach to the React Portal to a part of the DOM that is outside of your React app and that is guaranteed to be there document.body.

There are use cases where might want to render a portal in a very specific location in the DOM and this solution has worked for me. The target element being a DOM reference in our React app. We should create a div element to attach to just to be sure that the element is in the DOM. Finally we are removing the element on unmount to prevent memory leaks.

interface Props {
  state: { open: boolean };
  component: React.ReactNode; //component to render in the portal
}

const PortalComponent = React.forwardRef<HTMLDivElement, Props>(
  ({ state, component }, ref: ForwardedRef<HTMLDivElement>) => {
    if (!state.open) return null;

    const portalContainerRef = useRef<HTMLDivElement | null>(null);
    const targetEl = ref as React.MutableRefObject<HTMLDivElement | null>;

    useEffect(() => {
      const target = targetEl?.current;
      if (!target) return;

      if (!portalContainerRef.current) {
        portalContainerRef.current = document.createElement('div');
        // append the portal container to the target element
        target.appendChild(portalContainerRef.current);
      }

      return () => {
        if (portalContainerRef.current && target.contains(portalContainerRef.current)) {
          // remove the portal container from the target element to avoid memory leaks
          target.removeChild(portalContainerRef.current);
        }
        portalContainerRef.current = null;
      };
    }, [targetEl?.current]);

    if (!portalContainerRef.current) return null;

    return ReactDOM.createPortal(
      <div>
        {component}
      </div>,
      portalContainerRef.current
    );
  }
); 

and to use our Portal component it would look something like this :

import React, { useRef, useState } from 'react';
import { PortalComponent } from './PortalComponent';

const ExamplePortal: React.FC = () => {
  const [isOpen, setIsOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  const PortalContent = () => (
    <div>
      <h3>Portal Content</h3>
      <p>This content is rendered inside the portal</p>
    </div>
  );

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle Portal
      </button>

      <div ref={containerRef} style={{ position: 'relative', minHeight: '200px' }}>
        <PortalComponent
          ref={containerRef}
          state={{ open: isOpen }}
          component={<PortalContent />}
        />
      </div>
    </div>
  );
};

export default ExamplePortal;

Upvotes: 0

ptim
ptim

Reputation: 15607

Here's my take, moving complexity into the Portal:

import { useRef } from "react";
import { Portal } from "./Portal";

export default function App() {
  const appRef = useRef<HTMLDivElement>(null);

  return (
    <div className="App" ref={appRef}>
      <Portal>
        <p>document.body</p>
      </Portal>

      <Portal containerQuerySelector=".App">
        <p>.App</p>
      </Portal>

      <Portal containerRef={appRef}>
        <p>appRef</p>
      </Portal>
    </div>
  );
}

// Portal.tsx

import {
  Fragment,
  ReactElement,
  RefObject,
  useEffect,
  useReducer,
} from "react";
import { createPortal } from "react-dom";

type PortalProps<T> = (
  | {
      containerRef?: RefObject<T>;
      containerQuerySelector?: never;
    }
  | {
      containerQuerySelector?: string;
      containerRef?: never;
    }
) & {
  children?: ReactElement;
};

export const Portal = <T extends Element>({
  children,
  containerRef,
  containerQuerySelector,
}: PortalProps<T>) => {
  const [refreshKey, incrementRefreshKey] = useReducer((x: number) => x + 1, 0);

  // HACK: re-render when containerRef.current or document.QuerySelector resolves
  useEffect(incrementRefreshKey, [incrementRefreshKey]);

  const maybeQuerySelectorElement = containerQuerySelector
    ? document.querySelector(containerQuerySelector)
    : null;

  // if either containerQuerySelector or containerRef are passed, wait until they resolve
  if (
    (containerQuerySelector && !maybeQuerySelectorElement) ||
    (containerRef && !containerRef?.current)
  )
    return null;

  return createPortal(
    <Fragment key={refreshKey}>{children}</Fragment>,
    maybeQuerySelectorElement ?? containerRef?.current ?? document.body
  );
};

Upvotes: 1

Nicholas Tower
Nicholas Tower

Reputation: 85132

forwardRef is used when the parent component needs to get access to the child's element, not the other way around. To pass an element down to a child you'll do it through a prop. Also, since refs aren't populated until after the first render, you may need to render the parent component twice, once to put the container on the page, and then again to pass it to the child so the child can create a portal.

const Parent = () => {
  const ref = useRef<HTMLDivElement>(null);
  const [element, setElement] = useState<HTMLDivElement | null>(null);
  useEffect(() => {
    // Force a rerender, so it can be passed to the child.
    // If this causes an unwanted flicker, use useLayoutEffect instead
    setElement(ref.current); 
  }, []);

  return (
    <div ref={ref}>
      {element && (
        <Child element={element} {/* insert other props here */} />
      )}
    </div>
  )
}

const Child = (props: Props) => {
  const { state, options, dispatch, element } = props;
  if (!state.open) {
    return null;
  }

  return ReactDOM.createPortal(
    <div
      className={css`
        height: 140px;
        background: white;
        overflow-y: scroll;
        position: absolute;
        width: 100%;
      `}
    >
      {options}
    </div>,
    element
  );
}

Upvotes: 4

Related Questions