Last active
November 8, 2022 11:11
-
-
Save richardscarrott/9b0509d661966909fb982145e6fae939 to your computer and use it in GitHub Desktop.
React portal implementation supporting enter / exit animations
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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