Skip to content

Instantly share code, notes, and snippets.

@richardscarrott
Last active November 8, 2022 11:11
Show Gist options
  • Save richardscarrott/9b0509d661966909fb982145e6fae939 to your computer and use it in GitHub Desktop.
Save richardscarrott/9b0509d661966909fb982145e6fae939 to your computer and use it in GitHub Desktop.
React portal implementation supporting enter / exit animations
import React, {
createContext,
useState,
useCallback,
useMemo,
useContext,
useId,
useLayoutEffect,
useEffect,
} from "react";
// Merely used to prevent SSR warning
const useIsomorphicLayoutEffect =
typeof document === "undefined" ? useEffect : useLayoutEffect;
export interface PortalRegistry {
readonly add: (id: string, node: React.ReactNode) => void;
readonly remove: (id: string) => void;
readonly nodes: Map<string, React.ReactNode>;
}
const PortalContext = createContext<PortalRegistry | null>(null);
export interface PortalProviderProps {
readonly children: React.ReactElement;
}
export const PortalProvider = ({ children }: PortalProviderProps) => {
const [nodes, setNodes] = useState<Map<string, React.ReactNode>>(new Map());
const add = useCallback(
(id: string, node: React.ReactNode) => {
setNodes((prevNodes) => {
const nextNodes = new Map(prevNodes);
nextNodes.set(id, node);
return nextNodes;
});
},
[setNodes]
);
const remove = useCallback(
(id: string) => {
setNodes((prevNodes) => {
const nextNodes = new Map(prevNodes);
nextNodes.delete(id);
return nextNodes;
});
},
[setNodes]
);
const value = useMemo(
() => ({
add,
remove,
nodes,
}),
[add, remove, nodes]
);
return (
<PortalContext.Provider value={value}>{children}</PortalContext.Provider>
);
};
const usePortalRegistry = () => {
const context = useContext(PortalContext);
if (context == undefined) {
throw new Error(
"`usePortalRegistry` must be used within a <PortalProvider />"
);
}
return context;
};
interface PortalOutletProps {
readonly children?: (
nodes: React.ReactNode[]
) => React.ReactNode | React.ReactNode[];
}
export const PortalOutlet = ({
children = (nodes) => nodes,
}: PortalOutletProps) => {
const context = usePortalRegistry();
const nodes = Array.from(context?.nodes.values() ?? []);
return <>{children(nodes)}</>;
};
interface PortalProps {
readonly children?: React.ReactNode;
}
export const Portal = ({ children }: PortalProps) => {
const id = useId();
const context = usePortalRegistry();
// NOTE: In many scenarios (especially when animating out), you could get away
// with using `useEffect` rather than `useLayoutEffect` here; but for consistency
// when it's unmounted as a result of another update (e.g. navigating to another route),
// it technically needs to be a layout effect to stay in sync.
useIsomorphicLayoutEffect(() => {
context.add(id, children);
}, [children]);
useIsomorphicLayoutEffect(() => {
// NOTE: This cleanup can't occur in the above effect because removing then
// re-adding has the side effect of changing the order which is undesirable.
// (We only want to change the order if the portal is unmounted / remounted)
return () => context.remove(id);
}, []);
return null;
};
import { PortalProvider, PortalOutlet, Portal } from './portal.tsx';
export default function App() {
const [showA, setShowA] = useState(false);
const [showB, setShowB] = useState(false);
return (
<PortalProvider>
<div>
<button
onClick={() => {
setShowA((prev) => !prev);
}}
>
Toggle A
</button>
<button
onClick={() => {
setShowB((prev) => !prev);
}}
>
Toggle B
</button>
{showA ? <Modal>A</Modal> : null}
{showB ? <Modal>B</Modal> : null}
<PortalOutlet>
{(nodes) => (
<TransitionGroup>{nodes}</TransitionGroup>
)}
</PortalOutlet>
</div>
</PortalProvider>
);
}
const Modal = ({ children }: ModalProps) => {
const id = useId();
const nodeRef = React.useRef(null);
return (
<Portal>
<CSSTransition
nodeRef={nodeRef}
key={id}
timeout={250}
classNames={{
enter: "enter",
enterActive: "enterActive",
exit: "exit",
exitActive: "exitActive",
}}
>
<div
ref={nodeRef}
style={{
width: 500,
height: 300,
color: "white",
background: "gold",
}}
>
{children}
</div>
</CSSTransition>
</Portal>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment