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

推荐订阅源

N
News and Events Feed by Topic
Malwarebytes
Malwarebytes
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Cybersecurity and Infrastructure Security Agency CISA
F
Future of Privacy Forum
C
Cisco Blogs
T
The Exploit Database - CXSecurity.com
A
Arctic Wolf
S
Securelist
K
Kaspersky official blog
S
Schneier on Security
T
ThreatConnect
T
Tenable Blog
Spread Privacy
Spread Privacy
T
True Tiger Recordings
AWS News Blog
AWS News Blog
F
Fox-IT International blog
量子位
T
Threatpost
V
Vulnerabilities – Threatpost
C
CERT Recently Published Vulnerability Notes
Cisco Talos Blog
Cisco Talos Blog
GbyAI
GbyAI
宝玉的分享
宝玉的分享
腾讯CDC
G
Google Developers Blog
aimingoo的专栏
aimingoo的专栏
Cyberwarzone
Cyberwarzone
有赞技术团队
有赞技术团队
S
SegmentFault 最新的问题
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V
Visual Studio Blog
U
Unit 42
雷峰网
雷峰网
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Simon Willison's Weblog
Simon Willison's Weblog
O
OpenAI News
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
The Register - Security
The Register - Security
MyScale Blog
MyScale Blog
小众软件
小众软件
A
About on SuperTechFans
Last Week in AI
Last Week in AI
Y
Y Combinator Blog
博客园 - 三生石上(FineUI控件)
美团技术团队
Google Online Security Blog
Google Online Security Blog
P
Proofpoint News Feed
MongoDB | Blog
MongoDB | Blog

DEV Community

Gemma 4: The 128K Multimodal Powerhouse in Your Terminal How to Consolidate Your QA Toolstack: A Practical Buyer's Guide The Thank-You Email Almost Nobody Sends (And Why That's Your Edge) Schema Types 2026 Idempotency Keys: The API Safety Net You're Probably Not Using How to let Claude see my Plaid bank data Kiro Did It: Build a Simple Portfolio Website with Kiro IDE | From Prompt to HTML Prototype Islands of Commerce: What Marketplace Founders Can Learn from 60 Years of Island Biogeography Engineering decisions for my video call tool VBScript Still Lives: How a Custom Go VM Brought Classic ASP to Linux and Mac What Happens When You Teach Old Scripting Languages New Runtime Tricks? I Tested 6 AI Coding Assistants for a Month. Here's What Actually Works. Extendscript Still Has Life Afriex Webhook Integration Guide: Signature Verification, Event Handling, and Production Best Practices The Blind Alleys of Veltrix Configuration How an ESP32 Turned a LEGO WALL-E Into a Real Working Robot The Flawed Promise of Real-Time Event Handling SSH Login Taking Forever? Check Your DNS Settings Found 897 Fake Followers on DEV.to. Here's How I Proved It. Retry logic, Kafka consumer lag, and the hidden failure pattern that Kubernetes won’t catch WebMCP Might Be the Most Important Announcement at Google I/O 2026 Build a Secure API with Rails 8 - Part-3: Auth Controllers I A/B tested 4 LLMs on the same 500 queries. The results surprised me. Google I/O 2026’s Smartest Developer Release Wasn’t a Model, It Was the Runtime - Managed Agents in Gemini API OSS Monthly Recap: What My Daily Commit Challenge Taught Me About Open Source “Culture” GemmaNotes Cognitive Debt: AI Is Building Your Systems. Do You Actually Understand Them? GeekNews Frontend Weekly Deep Dive - 2026-05-25 I Built a Universal Silicon Loader That Runs on Any SOC (No Bootrom Exploit) Docker容器化部署Node.js应用最佳实践 I Put a Neural Network in a Thermometer — Then It Got Out of Hand Building MGZon: Developer Portfolio + AI Bot + Social Network (9 min demo) Bearing Life (L10): What the Catalog Number Really Tells You Longhorn Volume Health: The Gap Between 'Healthy' and Actually Working Stop Prompting. Start Specifying: How Spec-Driven Development Fixes AI Coding TIL a PowerPoint file is just a zip — so I converted .pptx to Word entirely in the browser 로컬 LLM 셋업 가이드 (v18) Cx Dev Log — 2026-04-24 github's agent audit api is the boring feature that matters # From Teaching Code to Building Real-World Applications Vivado 2026.1 and Linux: why this decision matters beyond the headline Vivado 2026.1 y Linux: por qué la decisión importa más allá del titular ORA-00206 오류 원인과 해결 방법 완벽 가이드 Entidades finas e composição: o design que escolhi para a nova plataforma 10 Open Source Tools Every Developer Should Know 🔥 SSH Config File Mastery: Turning `~/.ssh/config` Into a Productivity Tool I tried to create a programming language... in python I Replaced 70MB Node.js Log Viewer with a 172KB Zig Binary I Turned npm outdated into a CI Gate — Here's How Don't fall for the Claude Mythos hype Vestige: A Gemma 4 Brain Tracker That Won't Blow Smoke Up Your Ass Gemminate: Transforming Static Textbooks into Interactive Learning Journeys with Gemma 4 Where Did All the Code Playgrounds Go? I built PROOFER - Privacy first Chrome extension that proofreads your texts using Gemma 4 I Automated My Entire Digital Product Business on a $13/Month GCP VM. Here's the Architecture. Beginner's Mind in Engineering and AI How I use AI agents to turn ideas into public demos I Built a Quotation Generator for Kenyan Street Welders Using Gemma 4's Vision The Math Behind Neural Networks — Explained Like Nobody Did for Me 🧨 Understanding TPC with IEEE802.11h What I’m Starting to Look for in Engineers An npm Downloads Comparison Chart in 300 Lines of Vanilla JS — Nice-Tick Math and API-Direct Fetch Vitreus: Local-First Spreadsheet Intelligence with Gemma 4 Transfer Fees, Metadata, and Soulbound Tokens: A Tour of Solana Token Extensions I got tired of re-explaining my codebase to ChatGPT — so I built a VS Code extension Revisiting My Phone AI After Gemma 4: The Upgrade I Didn't Know I Needed I built a privacy-first PDF merger in 7 hours — here's the stack and the lessons Google I/O 2026 made me ask an uncomfortable question: are we still coding, or are we managing builders? SSR with JavaScript: Escaping Node.js Clunkiness with AxonASP My CKA Exam-Day Experience: What Went Right, What Went Wrong, and Lessons Learned Gemma 4 Soft Tokens: The Rise and Fall of 16x16 Words ⚡👀 Two weeks ago, I built a private AI brain on my phone using Gemma 4. Yesterday, Google dropped a new variant that made everything I built feel like a beta test. 256M parameters. MoE architecture. Apache 2.0 license. I broke down what changed and why it mat I got tired of clicking through the Stripe dashboard, so I built a CLI Getting Data from Multiple Sources in Power BI: A Practical Guide to Modern Data Integration Google Is No Longer Just a Search Engine I built GemmaPod - A truly composable and portable AI agent solution powered by your local LLM Gemma 4 E4B caught three planted fabrications in 50 seconds — on a laptop, no cloud How to build an AI-powered content moderation pipeline for user comments Running Gemma 4 on a Modest Machine: Unsloth vs LM Studio vs llama.cpp vs Ollama AI Makes Building Cheap. Our Product Architectures Still Assume It’s Expensive. I built an in-browser Roku TV remote with ~80 lines of TypeScript. Here's how Roku's ECP API actually works The Direction of Blame babbled notes: a sound-to-music agent for people who could not make music before How I Built a Live SQL Workshop Where Students Can't Break Anything Rescuing a Stranded Protocol: Re-Skinning Legacy Code for the Trestle DeFi Flywheel SOLID Heuristics Reveal Incomplete Domain Knowledge — Nothing More AllasCode Intitute / FullAgenticStack: The Intent-Based Router Introducing LogicGrid — Multi-Agent AI Orchestration for .NET AI Prompt Injection, Drupal SQLi Exploitation, and Nmap for Hardening AI Agents & Python Workflows: Anthropic Skills, Jupyter Challenges, and Edge Deployment SQLite Optimization, PostgreSQL Async Queries, & DuckLake Dataframe Spec RTX 5080 Undervolt Benchmarks, CGO-Free CUDA API Binding, & AMD GPU Compatibility Fix Microsoft Burned Its 2026 AI Budget on Claude Code in Six Months. That's the Real Story. Why I Started Learning FastAPI in 2026 I Abandoned Ghost for Months — Then Came Back and Finally Finished It Building an Open MIT-Licensed Ephemeris Engine in C — JPL Moshier Ephemeris 4 Smart Ways to Manage Retries in Side Projects Securing Web APIs: A Practical Guide to Authentication & Authorization Methods Google I/O 2026: AI Built an OS in 12 Hours. I Spent Mine Sorting Screenshots. 🤦 Half a Day, Not a Week: One Nix Flake for Three Machines
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>;
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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)}
    />
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>;
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.