Custom useHover Hook: Handle Mouse Events Like a Pro (2026)
Detecting when a user hovers over an element is one of the most common interactions in web applications. You might need it for tooltips, dropdown menus, hover effects, or interactive cards. Instead of scattering mouse event handlers throughout your components, a custom useHover hook encapsulates this logic beautifully and makes it reusable across your entire application.
In this guide, you'll learn how to build a production-ready useHover hook from scratch, understand the edge cases you need to handle, and see real-world implementations that work with modern React 19 patterns.
Table of Contents
- Understanding Hover State Detection
- Basic useHover Hook Implementation
- Handling Edge Cases and Accessibility
- Advanced Patterns: Debouncing and Performance
- Practical Example: Interactive Tooltip Component
- TypeScript Patterns and Type Safety
- FAQ
Understanding Hover State Detection
Before we build the hook, let's understand what we're really doing. A hover state is created when two things happen:
- The mouse enters an element (the
mouseenterevent) - The mouse leaves the element (the
mouseleaveevent)
The key insight here is that you need to manage a boolean state that tracks whether the element is currently being hovered. You also need a way to attach these event handlers to a DOM element—this is where React's useRef hook comes in handy.
When you use useRef in combination with a ref attribute on a JSX element, you get direct access to the underlying DOM node. From there, you can attach event listeners that update your hover state.
Many developers make the mistake of using onMouseEnter and onMouseLeave as props directly on the component. While that works, it's not reusable. The whole point of a custom hook is to extract this logic so you can use it anywhere without repeating yourself.
Basic useHover Hook Implementation
Let's start with the simplest version that actually works. Here's a basic useHover hook that you can use immediately in your projects:
TypeScript Version
import { useRef, useState } from 'react';
export function useHover<T extends HTMLElement = HTMLElement>() {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef<T>(null);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
// In real components, you'd attach these handlers via ref and events
// We'll show the full pattern below
return { ref, isHovered, handleMouseEnter, handleMouseLeave };
}
JavaScript Version
import { useRef, useState } from 'react';
export function useHover() {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef(null);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
return { ref, isHovered, handleMouseEnter, handleMouseLeave };
}
The hook returns four things:
ref: The ref object you attach to your target elementisHovered: The current boolean state of whether the element is being hoveredhandleMouseEnter: Function to call when mouse enters (you'd attach viaonMouseEnter)handleMouseLeave: Function to call when mouse leaves (you'd attach viaonMouseLeave)
Here's how you'd use it in a component:
TypeScript Component Usage
import { useHover } from './useHover';
interface CardProps {
title: string;
description: string;
}
export function HoverableCard({ title, description }: CardProps) {
const { ref, isHovered, handleMouseEnter, handleMouseLeave } = useHover<HTMLDivElement>();
return (
<div
ref={ref}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`card ${isHovered ? 'elevated' : ''}`}
>
<h3>{title}</h3>
<p>{description}</p>
{isHovered && <div className="hover-action">Click to learn more</div>}
</div>
);
}
JavaScript Component Usage
import { useHover } from './useHover';
export function HoverableCard({ title, description }) {
const { ref, isHovered, handleMouseEnter, handleMouseLeave } = useHover();
return (
<div
ref={ref}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`card ${isHovered ? 'elevated' : ''}`}
>
<h3>{title}</h3>
<p>{description}</p>
{isHovered && <div className="hover-action">Click to learn more</div>}
</div>
);
}
This is the foundational pattern, but there's a better way to structure this that requires less setup in the component itself.
Handling Edge Cases and Accessibility
The basic implementation above works, but there are subtle issues in production environments. Let's build a more robust version that handles real-world scenarios:
TypeScript Version (Improved)
import { useEffect, useRef, useState } from 'react';
interface UseHoverOptions {
onEnter?: () => void;
onLeave?: () => void;
delay?: number;
}
export function useHover<T extends HTMLElement = HTMLElement>(options?: UseHoverOptions) {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef<T>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
// Handle mouse enter with optional delay
const handleMouseEnter = () => {
if (options?.delay) {
timeoutRef.current = setTimeout(() => {
setIsHovered(true);
options?.onEnter?.();
}, options.delay);
} else {
setIsHovered(true);
options?.onEnter?.();
}
};
// Handle mouse leave and clear any pending timeout
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsHovered(false);
options?.onLeave?.();
};
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
// Cleanup: remove listeners and clear timeout when component unmounts
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [options?.delay, options?.onEnter, options?.onLeave]);
return { ref, isHovered };
}
JavaScript Version (Improved)
import { useEffect, useRef, useState } from 'react';
export function useHover(options) {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef(null);
const timeoutRef = useRef(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleMouseEnter = () => {
if (options?.delay) {
timeoutRef.current = setTimeout(() => {
setIsHovered(true);
options?.onEnter?.();
}, options.delay);
} else {
setIsHovered(true);
options?.onEnter?.();
}
};
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsHovered(false);
options?.onLeave?.();
};
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [options?.delay, options?.onEnter, options?.onLeave]);
return { ref, isHovered };
}
Key improvements in this version:
- Cleanup in useEffect: We properly remove event listeners when the component unmounts, preventing memory leaks
- Delay support: Sometimes you don't want instant hover effects. The
delayoption lets you wait a specified number of milliseconds before triggering the hover state - Callbacks: The
onEnterandonLeavecallbacks let you execute side effects when hover state changes - Timeout management: We track pending timeouts and clear them when the mouse leaves before the delay expires, or when the component unmounts
The useEffect dependency array includes the options to handle cases where callbacks change during the component's lifetime. In most real-world scenarios, these are stable, but including them makes the hook more correct and predictable.
Advanced Patterns: Debouncing and Performance
For interactive applications like those at ByteDance or Alibaba, you might have hover detection on many elements simultaneously. A simple hover hook can cause unnecessary re-renders. Here's a performance-optimized version:
TypeScript Version (Performance-Optimized)
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseHoverOptions {
onEnter?: () => void;
onLeave?: () => void;
delay?: number;
}
export function useHover<T extends HTMLElement = HTMLElement>(
options?: UseHoverOptions
) {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef<T>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Use useCallback to memoize handlers so they don't change on every render
const handleMouseEnter = useCallback(() => {
if (options?.delay) {
timeoutRef.current = setTimeout(() => {
setIsHovered(true);
options?.onEnter?.();
}, options.delay);
} else {
setIsHovered(true);
options?.onEnter?.();
}
}, [options?.delay, options?.onEnter]);
const handleMouseLeave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsHovered(false);
options?.onLeave?.();
}, [options?.onLeave]);
useEffect(() => {
const element = ref.current;
if (!element) return;
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [handleMouseEnter, handleMouseLeave]);
return { ref, isHovered };
}
JavaScript Version (Performance-Optimized)
import { useCallback, useEffect, useRef, useState } from 'react';
export function useHover(options) {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef(null);
const timeoutRef = useRef(null);
const handleMouseEnter = useCallback(() => {
if (options?.delay) {
timeoutRef.current = setTimeout(() => {
setIsHovered(true);
options?.onEnter?.();
}, options.delay);
} else {
setIsHovered(true);
options?.onEnter?.();
}
}, [options?.delay, options?.onEnter]);
const handleMouseLeave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsHovered(false);
options?.onLeave?.();
}, [options?.onLeave]);
useEffect(() => {
const element = ref.current;
if (!element) return;
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [handleMouseEnter, handleMouseLeave]);
return { ref, isHovered };
}
Why useCallback matters here:
When you pass callbacks to useCallback, you prevent unnecessary re-creation of the event handler functions. This is especially important if you're using this hook on dozens of elements. Without useCallback, the handlers would be recreated on every render, causing the effect to re-run and re-attach listeners constantly.
The dependency array in useCallback ensures that if the options change, new handlers are created. The dependency array in useEffect then depends on these memoized handlers, making the whole system efficient.
Practical Example: Interactive Tooltip Component
Let's see how all of this comes together in a real-world scenario. Imagine you're building a dashboard where users hover over metrics to see detailed information—similar to patterns used at major tech companies for data-heavy applications.
TypeScript Implementation
import { ReactNode } from 'react';
import { useHover } from './useHover';
interface TooltipProps {
children: ReactNode;
tooltipContent: string;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
}
export function Tooltip({
children,
tooltipContent,
tooltipPosition = 'top',
delay = 200,
}: TooltipProps) {
const { ref, isHovered } = useHover({ delay });
return (
<div ref={ref} className="tooltip-wrapper">
{children}
{isHovered && (
<div className={`tooltip tooltip-${tooltipPosition}`} role="tooltip">
{tooltipContent}
<div className="tooltip-arrow" />
</div>
)}
</div>
);
}
JavaScript Implementation
import { useHover } from './useHover';
export function Tooltip({
children,
tooltipContent,
tooltipPosition = 'top',
delay = 200,
}) {
const { ref, isHovered } = useHover({ delay });
return (
<div ref={ref} className="tooltip-wrapper">
{children}
{isHovered && (
<div className={`tooltip tooltip-${tooltipPosition}`} role="tooltip">
{tooltipContent}
<div className="tooltip-arrow" />
</div>
)}
</div>
);
}
And here's how you'd use this component:
export function Dashboard() {
return (
<div className="metrics">
<Tooltip tooltipContent="Total revenue for this quarter">
<div className="metric-card">
<h3>Revenue</h3>
<p>$2.5M</p>
</div>
</Tooltip>
<Tooltip
tooltipContent="Active users in the last 30 days"
tooltipPosition="right"
delay={100}
>
<div className="metric-card">
<h3>Active Users</h3>
<p>45K</p>
</div>
</Tooltip>
</div>
);
}
The delay of 200ms prevents tooltip "flashing" when users quickly move their mouse across the screen. The accessibility role attribute tells screen readers that this is a tooltip, which is important for users relying on assistive technologies.
TypeScript Patterns and Type Safety
When building useHover for TypeScript, there are some nuances worth understanding. The generic type parameter <T extends HTMLElement = HTMLElement> allows you to specify the exact type of element you're working with:
// For a div
const { ref: divRef, isHovered } = useHover<HTMLDivElement>();
// For a button
const { ref: buttonRef, isHovered } = useHover<HTMLButtonElement>();
// For a custom element (defaults to HTMLElement)
const { ref: genericRef, isHovered } = useHover();
This gives you better IntelliSense and type checking when you use the ref. The key principle is that the hook returns a ref object, which you always attach to a JSX element via the ref attribute. You never destructure the ref's current value inside the hook itself.
The options interface uses optional properties with the ? operator, allowing developers to use the hook with no configuration:
// Minimal usage
const { ref, isHovered } = useHover();
// With all options
const { ref, isHovered } = useHover({
delay: 300,
onEnter: () => console.log('entered'),
onLeave: () => console.log('left'),
});
FAQ
Q: Should I use mouseenter/mouseleave or mouseover/mouseout?
A: Use mouseenter and mouseleave. The key difference is that mouseenter doesn't bubble and doesn't trigger when moving between child elements. This is exactly what you want for hover detection. The mouseover and mouseout events bubble and can fire multiple times unexpectedly when hovering over child elements.
Q: Can I use this hook with touch devices?
A: Not directly. Mouse events don't fire on touch devices. If you need hover effects on touch devices, you'd need to add onTouchStart and onTouchEnd handlers. Consider using a library like react-use that handles this automatically, or extend useHover to accept a enableTouch option.
Q: Why not just use onMouseEnter and onMouseLeave directly in components?
A: You absolutely can for simple cases. But custom hooks shine when you need consistency across many components, additional logic like delays or callbacks, and reusability. If you have hover logic scattered across 50 components and need to add a delay, you'd change one hook instead of 50 components.
Q: How do I prevent the tooltip from blocking clicks on elements below it?
A: Use pointer-events: none in your CSS for tooltip elements. This makes them "transparent" to mouse events so clicks pass through to elements underneath. If you need the tooltip itself to be interactive, set pointer-events: auto on just the interactive parts.
Q: What about accessibility concerns with hover-only interactions?
A: Always provide an alternative way to access hover content. Never put critical information in hover-only tooltips. Use proper role and aria-label attributes. Consider using the onFocus and onBlur events alongside hover for keyboard users. The delay option helps prevent accidental triggering, and callbacks let you log analytics about user interactions.
Q: Should I use useRef or useState for the hovered element?
A: Always use useRef. The ref doesn't cause re-renders when accessed, and you're just storing a reference to the DOM node. useState would be incorrect here because you're not managing state related to rendering; you're just keeping a reference to a DOM element.
Related Articles
- Understanding React Hooks: useState, useEffect, and useRef
- Event Handling in React: Best Practices and Patterns
- React Performance Optimization: useMemo and useCallback
- Building Accessible Components with ARIA
Questions? Share your hover hook implementations and edge cases you've encountered in the comments below. Did you find this pattern useful? Let us know what kind of interactions you'd like to see covered next!
Google AdSense Placeholder
CONTENT SLOT