553

Track the mouse in React without extra re-renders

useMouse gives you direct DOM control by default and reactive state only when you actually need it.

August 9, 2026

reactuse ships 100+ hooks, and a whole family of them exists for one reason: the browser is a firehose of events. Scroll, resize, pointer movement, key presses — these fire dozens or hundreds of times per second. Hooks like useScroll and useMouse wrap those noisy event streams so you don't have to wire up listeners, compute deltas, and remember to clean everything up on unmount.

But mouse tracking in particular exposes a design problem that most hook libraries get wrong. This post is about how reactuse solves it — and why the useMouse API looks the way it does.

The basic shape

At its simplest, useMouse gives you a ref to attach and a snapshot of the current position:

import { useMouse } from '@siberiacancode/reactuse';
 
const Demo = () => {
  // attach the ref to the element you want coordinates relative to
  const mouse = useMouse<HTMLDivElement>();
  /* elementX / elementY are relative to the element, x / y are relative to the page */
  console.log(mouse.snapshot);
 
  // ...
};
 
export default Demo;

You can also pass a target directly (like window) instead of using the returned ref — the same hook handles both. That flexibility with refs is nice, but it isn't the interesting part. For more info read target.

The re-render problem

Here's the thing every React developer eventually hits: you cannot just hand back x and y as state.

The mouse moves continuously. If every mousemove triggered a setState, a component tree tracking the cursor would re-render hundreds of times per second. That's janky at best and a performance disaster at worst — especially if the coordinates flow down into a large subtree.

Most libraries "solve" this by bolting throttling onto the hook — you pass a timing option and the hook re-renders on a throttled interval:

// the typical "other library" approach — throttling baked into the hook
const { x, y } = useMouse({ throttleMs: 16 });

We think that's the wrong call, and we'll come back to why at the end. Reactuse takes a different route. By default, useMouse does not re-render your component at all.

Default: a callback and direct DOM control

Out of the box, useMouse gives you a callback that fires on every update, and a snapshot you can read imperatively. No state, no re-renders. This hands control to you — and the most powerful thing you can do with it is talk to the DOM directly, skipping React's render cycle entirely.

Here's a spotlight-card effect. The glow follows the cursor smoothly, but the React component renders exactly once:

import { useMouse } from '@siberiacancode/reactuse';
import { useRef } from 'react';
 
const Demo = () => {
  const spotlightRef = useRef<HTMLDivElement>(null);
 
  // the callback fires on every mouse move — but we never call setState.
  // instead we write straight to the DOM via CSS custom properties.
  const mouse = useMouse<HTMLDivElement>((value) => {
    const spotlight = spotlightRef.current;
    if (!spotlight) return;
    spotlight.style.setProperty('--x', `${value.elementX}px`);
    spotlight.style.setProperty('--y', `${value.elementY}px`);
  });
 
  return (
    <div ref={mouse.ref} className='group relative overflow-hidden rounded-2xl bg-neutral-950 p-8'>
      <div
        ref={spotlightRef}
        className='pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100'
        style={{
          background:
            'radial-gradient(300px circle at var(--x) var(--y), rgba(255,255,255,0.1), transparent 65%)'
        }}
      />
      <h2 className='relative text-xl font-bold text-white'>Ship faster with reactuse</h2>
    </div>
  );
};
 
export default Demo;

The cursor drives a radial-gradient at 60fps, and React is completely out of the loop. The hook computes element-relative coordinates for you; you decide what to do with them. This is the default because it's the fast path — and for effects like spotlights, parallax, custom cursors, or canvas drawing, it's exactly what you want.

When you actually need re-renders: watch()

Sometimes, though, you do want the coordinates in React state — to render them as text, to feed them into conditional logic, to drive other components. Throttling everything globally would be clumsy. So reactuse makes reactivity opt-in with a single method: watch().

Call watch() and the hook starts re-rendering on updates, returning the live value:

import { useMouse } from '@siberiacancode/reactuse';
 
const Demo = () => {
  const mouse = useMouse<HTMLDivElement>();
  // opt in to reactive updates — now the component re-renders on move
  const position = mouse.watch();
 
  return (
    <div ref={mouse.ref}>
      <p>x: {position.elementX}</p>
      <p>y: {position.elementY}</p>
    </div>
  );
};
 
export default Demo;

The mechanism is simple by design: internally the hook keeps the latest value in a ref and only triggers a re-render once watch() has been called. If you never call it, you never pay for it. One hook, two modes — and you choose which one, per component, by whether you read snapshot or call watch().

That's the part no other mouse hook we know of does: the same hook is both the zero-re-render imperative tool and the reactive one, without a config flag deciding your fate.

Why we don't add a throttleMs option

Throttling is a consumer concern. How often you want to react to the mouse depends on what you're building — a canvas tool wants every frame, a debug readout is fine at 4Hz, an analytics ping might want one event per second. Baking a throttle into the hook forces our opinion onto every use case and quietly couples the hook to a timing strategy.

Instead, the callback gives you the raw stream, and you compose whatever you need on top — throttle, debounce, requestAnimationFrame, or nothing at all:

import { useMouse, useThrottleCallback } from '@siberiacancode/reactuse';
 
const Demo = () => {
  // throttling is YOUR decision, layered on top — not baked into useMouse
  const onMove = useThrottleCallback((value) => {
    console.log(value.elementX, value.elementY);
  }, 100);
 
  const mouse = useMouse<HTMLDivElement>(onMove);
 
  return <div ref={mouse.ref} />;
};
 
export default Demo;

A good hook API stays small and unopinionated at its core. It exposes the primitive — the event stream and a way to opt into reactivity — and lets you assemble the rest from other composable hooks. Piling options like throttleMs onto every hook bloats the surface and makes each one harder to learn, harder to type, and harder to trust.

Takeaways

  • reactuse has a family of hooks for noisy browser events; useMouse is the sharpest example of the design.
  • Default is callback + snapshot: no re-renders, direct DOM control — ideal for spotlights, cursors, canvas, parallax.
  • watch() is opt-in reactivity: call it only in the components that truly need coordinates in state.
  • We keep the API minimal on purpose — throttling and debouncing are the consumer's responsibility, composed from other hooks rather than stuffed into options.