Reputation: 215
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
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
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
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