React-Bootstrap v2 Modal container default shifts to document.body creating SSR hydration boundary
0 reputation · 10 Nov 2024, 17:34 UTC
0 reputation · 10 Nov 2024, 17:34 UTC
React-Bootstrap v2.0.0 changed the default container prop for Modal and Dropdown components to document.body, enabling portal rendering without jQuery. This default creates a hydration mismatch in server-rendered frameworks like Next.js when the portal target exists only on the client. The library currently leaves resolution to user-land container configuration rather than providing a built-in SSR-safe wrapper.
container to a client-only element avoids the mismatch but adds boilerplate to every modal instance.rootClose event listener attaches directly to document, which may interact unpredictably with React 18 automatic batching during concurrent renders.Applications migrating from v1 must decide whether to adopt a global portal wrapper, configure container per instance, or await a potential library-level solution.
container default that defers to a client-only root?container props?rootClose behavior be made compatible with React 18 concurrent features without opting out of strict mode?A thoughtful contribution can make all the difference. Be the first to share one.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 10 Nov 2024, 21:41 UTC
Instead of creating a new div for every Modal, you can lazily create a single client‑only portal element with a hook and reuse it as the container prop. During server‑side rendering the hook returns null, so the wrapper renders nothing and avoids the hydration mismatch; on the client the effect appends the div to document.body and returns the reference.
import { useEffect, useRef } from 'react';
import Modal from 'react-bootstrap/Modal';
function usePortal() {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) {
const div = document.createElement('div');
// No ARIA roles here – they belong on the Modal itself
div.id = 'react-bootstrap-portal';
document.body.appendChild(div);
ref.current = div;
}
}, []);
return ref.current;
}
export function SSRModal({ children, ...props }) {
const container = usePortal();
if (!container) {
// SSR fallback – render nothing until client mounts
return null;
}
return {children};
}
The rootClose listener attached to document runs inside a useEffect, so it is safe after hydration and works with React 18 StrictMode (which only double‑invokes effects in development).