machine coding11
react ui components
contextcompound-componentsrefs

Popover

Build a popover where the trigger and content are separate components.

medium1 min readsolution

What it tests

Sharing state between sibling components without the caller having to wire it. The consumer should write <Popover><Trigger /><Content /></Popover> and not manage an isOpen boolean themselves.

The approach

The compound-component pattern. A provider owns the open state and the parts read it from context, so they coordinate no matter how they're nested:

const PopoverContext = createContext(null);
 
export function usePopoverContext() {
  const ctx = useContext(PopoverContext);
  if (!ctx) throw new Error("Popover parts must be used inside <Popover>");
  return ctx;
}

Throwing from the hook when the context is missing turns a confusing null dereference into a message that names the actual mistake.

Where it usually goes wrong

  • Prop-drilling isOpen. Pushes the state back to the caller, which is the thing the component was meant to hide.
  • cloneElement on children. Breaks the moment a part is wrapped in a div.
  • No outside-click or Escape handling. Both are expected of a popover, and both need a listener that's cleaned up.
  • Clipping. A popover inside overflow: hidden gets cut off; that's what portals are for.

Full solution

1 file from src/folders/popover.

use-popover-context.jsx

/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useRef, useState } from "react";
import { createPortal } from "react-dom";
 
const PopOverContext = createContext(null);
 
function PopOverProvider({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentRef = useRef(null);
 
  function handlePopOver() {
    setIsOpen((prev) => !prev);
    const { left, right } = contentRef.current?.getBoundingClientRect() ?? {};
    console.log(left, right);
  }
 
  return (
    <PopOverContext.Provider
      value={{ isOpen, contentRef, handlePopOver, setIsOpen }}
    >
      {children}
    </PopOverContext.Provider>
  );
}
 
function usePopOverContext() {
  const ctxt = useContext(PopOverContext);
  if (!ctxt) throw new Error("usePopOverContext used outside PopOverProvider");
  return ctxt;
}
 
function PopOver({ children }) {
  return <>{children}</>;
}
 
function Action({ children }) {
  const { handlePopOver } = usePopOverContext();
  return <button onClick={handlePopOver}>{children}</button>;
}
 
function Content({ children }) {
  const { isOpen, contentRef } = usePopOverContext();
  if (!isOpen) return null;
  return createPortal(<div ref={contentRef}>{children}</div>, document.body);
}
 
PopOver.Action = Action;
PopOver.Content = Content;
 
export { usePopOverContext, PopOverProvider, PopOver };
share this post