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.
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");
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);
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);
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);
const handler = spy();
renderHook(() => useClickOutside({ current: null }, handler));
fireEvent.pointerDown(document.body);
expect(handler.callCount).toBe(0);
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);
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();
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).
I'm here to help with useClickOutside. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.