React UI Components

Popover

Build a popover where the trigger and content are separate components.
medium1 min readsolution
contextcompound-componentsrefs

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