Skip to the code
cf.

useClickOutside

Write useClickOutside(ref, handler) and export it from useClickOutside.js. The tea shop's menus and popovers close when you press anywhere else on the page. ref is a ref attached to the element (the popover), and handler(event) is called when a pointer goes down outside it.

Listen for pointerdown on document. Pointer events cover a mouse, a pen, and a finger on a touch screen, so one listener handles taps too. A press on the element itself, or on anything inside it, isn't outside. Read ref.current when the press happens, because the element may appear after the first render. While ref.current is null, do nothing.

Always call the latest handler: components pass a new arrow function on every render, and it may read their current state. Stop listening when the component goes away.

function TeaPicker() {
  const [open, setOpen] = useState(false);
  const picker = useRef(null);
  useClickOutside(picker, () => setOpen(false));
  return (
    <div ref={picker}>
      <button onClick={() => setOpen(!open)}>Choose tea</button>
      {open && <div role="dialog" aria-label="Teas">…</div>}
    </div>
  );
}

The Preview shows Demo.jsx, a tea picker that uses your hook.

What the tests check

  1. calls the handler when you press outside
    function Popover({ onOutside }) {
      const ref = React.useRef(null);
      useClickOutside(ref, onOutside);
      return (
        <div>
          <div ref={ref} role="dialog" aria-label="Teas">
            <button>Sencha</button>
          </div>
          <button>Elsewhere</button>
        </div>
      );
    }
    const handler = spy();
    render(<Popover onOutside={handler} />);
    await user.click(screen.getByRole("button", { name: "Elsewhere" }));
    expect(handler.callCount).toBe(1);
    expect(handler.calls[0].args[0].type).toBe("pointerdown");
    
  2. pressing the element, or anything inside it, doesn't call it
    function Popover({ onOutside }) {
      const ref = React.useRef(null);
      useClickOutside(ref, onOutside);
      return (
        <div>
          <div ref={ref} role="dialog" aria-label="Teas">
            <button>Sencha</button>
          </div>
          <button>Elsewhere</button>
        </div>
      );
    }
    const handler = spy();
    render(<Popover onOutside={handler} />);
    await user.click(screen.getByRole("dialog", { name: "Teas" }));
    await user.click(screen.getByRole("button", { name: "Sencha" }));
    expect(handler.callCount).toBe(0);
    
  3. a tap on a touch screen counts too
    function Popover({ onOutside }) {
      const ref = React.useRef(null);
      useClickOutside(ref, onOutside);
      return (
        <div>
          <div ref={ref} role="dialog" aria-label="Teas">
            <button>Sencha</button>
          </div>
          <button>Elsewhere</button>
        </div>
      );
    }
    const handler = spy();
    render(<Popover onOutside={handler} />);
    fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" }), { pointerType: "touch" });
    expect(handler.callCount).toBe(1);
    
  4. calls the latest handler
    function Popover({ onOutside }) {
      const ref = React.useRef(null);
      useClickOutside(ref, onOutside);
      return (
        <div>
          <div ref={ref} role="dialog" aria-label="Teas">
            <button>Sencha</button>
          </div>
          <button>Elsewhere</button>
        </div>
      );
    }
    const first = spy();
    const second = spy();
    const { rerender } = render(<Popover onOutside={first} />);
    rerender(<Popover onOutside={second} />);
    await user.click(screen.getByRole("button", { name: "Elsewhere" }));
    expect(first.callCount).toBe(0);
    expect(second.callCount).toBe(1);
    
  5. does nothing while ref.current is null
    const handler = spy();
    renderHook(() => useClickOutside({ current: null }, handler));
    fireEvent.pointerDown(document.body);
    expect(handler.callCount).toBe(0);
    
  6. stops listening when the component goes away
    const element = document.createElement("div");
    document.body.append(element);
    const handler = spy();
    const { unmount } = renderHook(() => useClickOutside({ current: element }, handler));
    fireEvent.pointerDown(document.body);
    expect(handler.callCount).toBe(1);
    unmount();
    fireEvent.pointerDown(document.body);
    expect(handler.callCount).toBe(1);
    
  7. works for an element that appears after the first render
    function TeaPicker() {
      const [open, setOpen] = React.useState(false);
      const ref = React.useRef(null);
      const close = React.useCallback(() => setOpen(false), []);
      useClickOutside(ref, close);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Choose tea</button>
          {open && (
            <div ref={ref} role="dialog" aria-label="Teas">
              <button>Sencha</button>
            </div>
          )}
          <p>Our teas</p>
        </div>
      );
    }
    render(<TeaPicker />);
    await user.click(screen.getByRole("button", { name: "Choose tea" }));
    await user.click(screen.getByRole("button", { name: "Sencha" }));
    expect(screen.getByRole("dialog", { name: "Teas" })).toBeInTheDocument();
    await user.click(screen.getByText("Our teas"));
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
    

Learn more

useClickOutside.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).