惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
GbyAI
GbyAI
阮一峰的网络日志
阮一峰的网络日志
Cloudbric
Cloudbric
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
I
Intezer
Simon Willison's Weblog
Simon Willison's Weblog
博客园_首页
The Cloudflare Blog
C
Cisco Blogs
AWS News Blog
AWS News Blog
IT之家
IT之家
Cyberwarzone
Cyberwarzone
罗磊的独立博客
美团技术团队
V
V2EX
Project Zero
Project Zero
A
Arctic Wolf
C
Cyber Attacks, Cyber Crime and Cyber Security
大猫的无限游戏
大猫的无限游戏
博客园 - 叶小钗
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 聂微东
有赞技术团队
有赞技术团队
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
雷峰网
雷峰网
S
Schneier on Security
P
Privacy International News Feed
V
Visual Studio Blog
量子位
T
Tor Project blog
S
Securelist
腾讯CDC
A
About on SuperTechFans
T
Threat Research - Cisco Blogs
G
GRAHAM CLULEY
B
Blog RSS Feed
D
DataBreaches.Net
博客园 - 三生石上(FineUI控件)
B
Blog
NISL@THU
NISL@THU
L
Lohrmann on Cybersecurity
V
Vulnerabilities – Threatpost
人人都是产品经理
人人都是产品经理
博客园 - 【当耐特】
L
LINUX DO - 热门话题
Recorded Future
Recorded Future

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
React Pointer Hooks: Hover, Long-Press, Double-Click, Scratch, and Click-Outside Without the Bugs
reactuse.com · 2026-05-25 · via DEV Community

React Pointer Hooks: Hover, Long-Press, Double-Click, Scratch, and Click-Outside Without the Bugs

Pointer events are the part of React nobody writes about because everybody assumes they have already been figured out. They have not. The standard answers — onMouseEnter, onClick, a setTimeout for double-click, a window listener for click-outside — all work in the demo and all break in production. They flicker as the cursor crosses a child element. They fire an iOS ghost click 300 ms after a touch ends. They miss elements rendered through a portal. They count a double-click as two single-clicks because the second click handler runs before the first one is cancelled.

The DOM event model is what it is. Browsers ship different gesture pipelines on mobile and desktop, the spec for dblclick is older than React, and composedPath() is the only reliable way to walk a click out through shadow boundaries and portals. None of that is going to change. What you can change is whether every component in your app re-implements the workarounds from scratch.

ReactUse ships six small pointer hooks that close the gaps. This post walks each one: the bug in the naive version, what the hook does instead, and a concrete component you would actually build with it. If you read the post on the ref escape hatch, one detail will look familiar — most of these hooks use useLatest internally so that the listener stays stable even as the callback identity moves.

Why Pointer Events Are a Swamp

A two-line example. A dropdown that closes when you click outside it:

function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handler(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, []);

  return <div ref={ref}>{open && <Menu />}</div>;
}

Four things wrong with this. First, no touchstart listener, so it does not close on mobile. Second, contains does not cross portals — if <Menu /> renders into document.body, clicking the menu items closes the menu. Third, the listener uses the bare Element.contains check instead of composedPath(), so anything inside a shadow root inside the dropdown is treated as outside. Fourth, the handler captures the initial setOpen closure; if the parent passes a new onClose prop, the listener still calls the old one because the effect only re-binds on mount.

Each of those is a one-line fix. Each of those one-line fixes is what makes the hook below 25 lines instead of 5. That is the whole pitch.

1. useHover — Hover State That Does Not Flicker

useHover returns a boolean for whether the cursor is currently inside a target element. The signature is exactly what you would write yourself:

import { useRef } from 'react';
import { useHover } from '@reactuses/core';

function Tooltip({ children, label }: { children: React.ReactNode; label: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const hovered = useHover(ref);

  return (
    <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
      {children}
      {hovered && <div className="tooltip">{label}</div>}
    </div>
  );
}

Two details matter. The hook listens to mouseenter and mouseleave, not mouseover and mouseout. mouseover bubbles, which means the cursor crossing into any child element fires another event and you spend most of your time flickering between true and false if you are not careful. mouseenter does not bubble — it fires once when the cursor enters the bounding element and once when it leaves, regardless of how many children sit underneath. This is the same reason CSS :hover does not flicker on nested elements: the browsers built the right primitive, they just hid it behind a less-obvious event name.

The other detail is that useHover takes a target ref, not a callback ref. The hook resolves the target through ReactUse's BasicTarget helper, which means you can pass a ref, a DOM node, or a function that returns one — useful when the element comes from another hook like useDraggable.

2. useMousePressed — Pressed State, Plus Where the Press Came From

hovered tells you if the pointer is over the element. useMousePressed tells you if the pointer is down on it — distinguishing mouse, touch, and drag as separate sources so you can react differently to each.

import { useRef } from 'react';
import { useMousePressed } from '@reactuses/core';

function PressyButton({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLButtonElement>(null);
  const [pressed, source] = useMousePressed(ref, { touch: true, drag: false });

  return (
    <button
      ref={ref}
      className={pressed ? 'pressed' : ''}
      data-source={source} // 'mouse' | 'touch' | null
    >
      {children}
    </button>
  );
}

Two values come back as a tuple: the boolean and a sourceType of 'mouse' | 'touch' | null. The source matters more than it looks. A touch press should not animate a hover-style transition because the user's finger is covering the element. A drag-start press should not trigger the button's onClick — you can use the source to decide whether to ignore the release. The hook handles the listener cleanup including the dragend and touchcancel paths that are easy to forget; if you have ever shipped a button that stayed in the "pressed" state because the user dragged off it, you have shipped the bug this hook closes.

There is a subtle thing about the listener targets too. mousedown is attached to the element, but mouseup and mouseleave are attached to the window. That is intentional: if the user presses on the button and releases outside it, you want to see the release. Attaching mouseup to the element itself misses that case — the button stays "pressed" until the user comes back and clicks it again.

3. useLongPress — Tap-and-Hold Without the iOS Ghost Click

A long-press is a tap held for a configurable duration before firing. The naive version is a setTimeout on mousedown cleared by mouseup:

function LongPressable({ onLongPress }: { onLongPress: () => void }) {
  const timer = useRef<number>();
  return (
    <div
      onMouseDown={() => { timer.current = window.setTimeout(onLongPress, 500); }}
      onMouseUp={() => clearTimeout(timer.current)}
    />
  );
}

That works on desktop. On iOS Safari, after the user lifts their finger from a long press, the OS fires a synthetic click event 300 ms later — the "ghost click" — which can trigger an unrelated handler on whatever element the finger landed on next. The fix is to attach a one-shot touchend listener with preventDefault to the element that was pressed, which is exactly the bookkeeping useLongPress does for you:

import { useLongPress } from '@reactuses/core';

function MessageBubble({ message }: { message: Message }) {
  const [showActions, setShowActions] = useState(false);

  const longPress = useLongPress(
    () => setShowActions(true),
    { delay: 500, isPreventDefault: true },
  );

  return (
    <div className="bubble" {...longPress}>
      {message.text}
      {showActions && <ActionSheet onClose={() => setShowActions(false)} />}
    </div>
  );
}

The hook returns an object of event handlers you spread onto the element — onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd — so the listener wiring lives inside React's synthetic event system instead of a raw addEventListener. That matters because synthetic events get batched correctly with React's state updates; a long-press that opens a modal will not produce two extra renders the way a manual addEventListener would.

isPreventDefault defaults to true and is the setting you want for almost every use case except scrolling. The one case where you want it off: when the long-press target is something the user might also want to scroll past, like a list item where the long-press opens a context menu but a vertical swipe should still scroll the list.

4. useDoubleClick — One Click vs Two, Without the Race

The browser ships a dblclick event, but it fires in addition to two click events, not instead of them. If you wire up both onClick and onDoubleClick, every double-click also triggers two single-click handlers. The standard fix is a debounce window — count clicks, wait for the gap, then dispatch single or double based on the count:

import { useRef } from 'react';
import { useDoubleClick } from '@reactuses/core';

function FileRow({ file }: { file: File }) {
  const ref = useRef<HTMLDivElement>(null);

  useDoubleClick({
    target: ref,
    latency: 250,
    onSingleClick: () => selectFile(file),
    onDoubleClick: () => openFile(file),
  });

  return <div ref={ref} className="row">{file.name}</div>;
}

useDoubleClick takes a target plus two callbacks and a latency. Click once, wait latency ms; if nothing else arrives, it is a single click. Click twice within latency, it is a double-click and the single-click never fires. The default latency of 300 ms matches what most desktop file managers use; you can pull it down to 200 ms for snappier UI or push it up to 500 ms if you are building something for older users or touch-first interfaces.

The hook also calls preventDefault on touchend events to head off iOS's "double-tap to zoom" behavior, which would otherwise zoom the page when a user double-taps a list row. That is one of those defaults you do not notice until it is missing and a beta tester files a bug.

5. useClickOutside — Dismiss on Outside Click, Through Portals

useClickOutside (also exported as useClickAway for parity with the older API name) is the "dismiss when the user clicks anywhere else" hook. The naive contains check breaks on portals and shadow DOM; the hook uses composedPath() instead, which walks the full event path including across shadow boundaries and across portals into their logical parents.

import { useRef, useState } from 'react';
import { useClickOutside } from '@reactuses/core';

function Popover({ trigger, children }: { trigger: React.ReactNode; children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useClickOutside(ref, () => setOpen(false));

  return (
    <div ref={ref} className="popover-root">
      <button onClick={() => setOpen((o) => !o)}>{trigger}</button>
      {open && <div className="popover-content">{children}</div>}
    </div>
  );
}

The hook listens to both mousedown and touchstart, not click. mousedown fires before mouseup and before click, which means the dropdown closes as soon as the press happens — before the click event would have triggered any handler on the element the user is pressing on. That feels right. If you listened to click instead, the click handler on the target would run before the dropdown closed, and if that handler also opened a modal, you would see the modal flash open and then the dropdown's close ripple through.

The third argument is an enabled boolean. Pass false when the menu is hidden to avoid running the listener at all — small thing, but if you have fifty dropdowns on a page you have fifty global mousedown listeners, and the cost adds up.

One thing to be aware of: the hook closes over handler through useLatest, so the listener stays stable even if you pass a new function on every render. That means you can write useClickOutside(ref, () => setOpen(false)) inline without worrying about the listener re-binding — same trick the ref escape hatch post covers in detail.

6. useScratch — Relative Pointer Position During a Drag

useScratch is the workhorse for any UI that needs to know where inside an element the pointer is during a drag — color pickers, signature pads, marquee selection, slider thumbs that need pixel-perfect tracking. The hook returns a state object containing the press's start position, the current position, the delta from the previous frame, and whether a scratch is in progress.

import { useRef } from 'react';
import { useScratch } from '@reactuses/core';

function ColorPicker() {
  const ref = useRef<HTMLDivElement>(null);
  const { x, y, isScratching } = useScratch(ref);

  const hue = x != null ? (x / 240) * 360 : 0;

  return (
    <div
      ref={ref}
      style={{
        width: 240,
        height: 24,
        background: 'linear-gradient(to right, red, yellow, lime, cyan, blue, magenta, red)',
        position: 'relative',
        cursor: 'crosshair',
      }}
    >
      {x != null && (
        <div
          style={{
            position: 'absolute',
            left: x - 2,
            top: 0,
            width: 4,
            height: 24,
            background: isScratching ? '#000' : '#444',
            pointerEvents: 'none',
          }}
        />
      )}
    </div>
  );
}

Two implementation details are worth knowing. First, the position updates run through useRafState so React re-renders at most once per animation frame — you can drag a finger across the element at 120 Hz and your component still renders at 60. Without rAF batching, a fast drag generates one render per mousemove, and on a high-DPI touchscreen that is hundreds per second.

Second, the hook attaches its mousemove and mouseup listeners to the document, not the element, while only mousedown is on the element. That is the same reason useMousePressed listens on the window — once the press starts, the drag can leave the original bounding box and you still want to track it. If the listeners were on the element, the user would only have to drag a few pixels outside before the gesture broke.

The callbacks — onScratch, onScratchStart, onScratchEnd — are read through a useLatest ref, so you can pass closures that capture component state without breaking memoization. Useful for the signature-pad pattern, where onScratch needs to draw onto a canvas using the latest strokeColor.

Putting It Together: A Context Menu

A small example that uses four of these hooks together. Long-press to open a context menu, the menu dismisses on outside click, the trigger shows a pressed state while the press is in progress, and the items in the menu support double-click to perform a "default action":

import { useRef, useState } from 'react';
import {
  useLongPress,
  useMousePressed,
  useClickOutside,
  useDoubleClick,
} from '@reactuses/core';

function ContextMenuItem({ label, onSelect }: { label: string; onSelect: () => void }) {
  const ref = useRef<HTMLLIElement>(null);
  useDoubleClick({
    target: ref,
    latency: 200,
    onSingleClick: () => {/* hover-equivalent: do nothing */},
    onDoubleClick: onSelect,
  });
  return <li ref={ref}>{label}</li>;
}

function ContextTarget({ items }: { items: Array<{ label: string; onSelect: () => void }> }) {
  const triggerRef = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLUListElement>(null);
  const [open, setOpen] = useState(false);

  const [pressed] = useMousePressed(triggerRef, { drag: false });
  const longPress = useLongPress(() => setOpen(true), { delay: 400 });

  useClickOutside(menuRef, () => setOpen(false), open);

  return (
    <>
      <div
        ref={triggerRef}
        className={`target ${pressed ? 'pressed' : ''}`}
        {...longPress}
      >
        Hold me
      </div>
      {open && (
        <ul ref={menuRef} className="menu">
          {items.map((item) => (
            <ContextMenuItem key={item.label} {...item} />
          ))}
        </ul>
      )}
    </>
  );
}

Four hooks, each ten lines of caller code. The equivalent component without them is roughly 120 lines once you have handled the iOS ghost click, the portal-aware click-outside, the rAF-batched pressed state, and the single-vs-double dispatch. That ratio — ten lines of intent vs a hundred lines of plumbing — is the case for picking up the library instead of pasting the same workaround into ten components.

When to Reach for Which

You want to react to Use
Cursor entering / leaving an element useHover
Pointer is currently down on an element useMousePressed
Tap-and-hold for N ms (especially on mobile) useLongPress
Single vs double click with no double-fire useDoubleClick
Click anywhere outside an element (dropdown, modal, popup) useClickOutside
Where inside an element a drag is happening useScratch

Two non-rules. If you want a draggable element that moves with the pointer (a panel, a sticky note), reach for useDraggable instead — useScratch gives you coordinates but does not move the element. And if you want focus, not press, use useFocus or useActiveElement; a "pressed" button and a "focused" button are different things and you usually want both.

Installation

npm install @reactuses/core
# or
pnpm add @reactuses/core
# or
yarn add @reactuses/core

All six hooks tree-shake individually — importing useHover does not pull in useScratch. Each ships TypeScript types and works in both client-rendered apps and SSR frameworks (Next.js, Remix, Astro); the listeners that need a DOM no-op on the server, and the hooks return safe defaults until hydration.

Related Hooks

If pointer interactions are your bottleneck, two adjacent ReactUse posts are worth a read. Observer hooks covers useIntersectionObserver, useResizeObserver, and useMutationObserver — the right primitives when "user did X" should become "element is in state Y". The ref escape hatch post covers useLatest and useEvent, which are what every hook in this post uses internally to stay closure-safe; understanding them makes the source of these gesture hooks much easier to read.

Browse the full set at reactuse.com, or open one of the hooks above and read the source — most are under 40 lines, and you will probably find one or two you have been re-implementing in your own codebase for years.