react-click-away-listener
A tiny (~700B) React hook-based component that detects clicks outside its child element to close menus, modals, and dropdowns.
Repository Health
Technical Analysis
react-click-away-listener is a minimal React component built on hooks that wraps a single child element and fires a callback whenever a click, tap, or focus event happens outside of it. It ships as a sub-1KB dependency with full TypeScript types, dual ESM/CJS builds, and support for React 16 through 19, including React 19’s changed ref-forwarding behavior and React Portals.
Under the hood it listens for the configured mouse, touch, and focus events on the child’s owning document (so it works correctly inside iframes and portalled UI), while filtering out events that bubble up from inside the wrapped element to avoid false-positive triggers. This makes it a drop-in building block for dropdown menus, modals, popovers, and any UI pattern that needs to know when the user interacted elsewhere on the page.
What You Get
- A ~700 byte, dependency-free
<ClickAwayListener>component with full TypeScript type definitions - Dual ESM (
dist/index.mjs) and CommonJS (dist/index.js) builds with matching.d.ts/.d.mtstype files - Configurable mouse (
click/mousedown/mouseup), touch (touchstart/touchend), and focus (focusin/focusout) event triggers, plus anextraEventsescape hatch for arbitrary DOM events - Correct behavior across React Portals, iframes (via
ownerDocumentlookup), and React 19’s updated ref-forwarding model
Common Use Cases
- Closing a dropdown menu when the user clicks anywhere outside it
- Dismissing a modal or popover on an outside click or tap
- Collapsing a mobile navigation drawer when focus or touch moves away from it
- Building custom select/autocomplete widgets that need outside-click-to-close behavior
Under The Hood
Architecture
The entire component lives in one file, src/index.tsx, structured as a single functional component (ClickAwayListener) with no internal module boundaries to speak of — appropriate for its scope. It tracks state with three refs (node, bubbledEventTarget, mountedRef) instead of component state, avoiding re-renders on every event. A mergeRefs helper combines the internal ref with the child’s own ref (branching on React’s major version to support both the legacy ref prop and React 19’s children.props.ref), and React.cloneElement attaches the merged ref plus per-event-type handlers directly onto the single child rather than introducing a wrapper DOM node. Event listeners are registered on the child’s ownerDocument in a useEffect keyed on the configurable event props, with the onClickAway callback itself held in a ref so listeners don’t need to be torn down and re-attached on every callback identity change.
Tech Stack
Written in TypeScript (strict mode) and built with Rollup (@rollup/plugin-typescript, @rollup/plugin-terser, @rollup/plugin-node-resolve) into separate CJS and ESM bundles with hand-copied .d.mts declarations. react/react-dom are peer dependencies spanning versions 16 through 19. Tests run under Jest with jest-environment-jsdom and @testing-library/react, transpiled via Babel (@babel/preset-env, -react, -typescript) rather than through the Rollup pipeline. Releases are automated with semantic-release and husky git hooks gate commits, with GitHub Actions workflows for PR builds, main-branch tests, CodeQL analysis, coverage reporting, and npm publishing.
Code Quality
The test suite in __tests__/index.tsx is table-driven (it.each) and exercises every configurable mouse, touch, and focus event combination, verifies bubbled events are correctly ignored, and checks portal-mounted usage — a deliberate response to a documented upstream React portal timing bug (facebook/react#20074) that the code works around with a one-tick mount guard. Coverage is tracked via Coveralls. ESLint is configured with eslint-plugin-react, react-hooks, jsx-a11y, and Prettier integration; lint-staged enforces formatting pre-commit. The single source file favors small, well-named helpers (mergeRefs, handleBubbledEvents, eventTypeMapping) over abstraction layers, appropriate for a ~150-line component.
API Design
The public surface is intentionally small: one component, one required prop (onClickAway), and four optional props to customize which mouse/touch/focus event triggers it and to add arbitrary extra events. It requires exactly one child (enforced via React.Children.only), which keeps the mental model simple but means multi-element children must be wrapped in a Fragment — a caveat the README documents explicitly along with the exact error message a consumer would hit. There’s no configuration object or provider to set up; it’s used inline at the call site with no boilerplate, which is the main ergonomic tradeoff it makes in exchange for that one-child restriction.