A custom React hook for handling double-click and double-tap events reliably across all platforms, with special optimizations for iOS devices.
iOS devices have historically had issues with double-click detection due to:
- The 300ms click delay on older iOS versions
- Conflicts between touch and mouse events
- Unreliable native double-click detection
- Ghost clicks (duplicate events after touch events)
The useDoubleClick hook provides a robust solution that:
- Uses native touch events for better iOS responsiveness
- Prevents ghost clicks through intelligent timing detection
- Handles both single and double-click scenarios
- Works consistently across iOS, Android, and Desktop platforms
The hook is located at src/hooks/use-double-click.ts and can be imported directly:
import { useDoubleClick } from '@/hooks/use-double-click';interface UseDoubleClickOptions {
onSingleClick?: (event: MouseEvent | TouchEvent) => void;
onDoubleClick: (event: MouseEvent | TouchEvent) => void;
delay?: number; // Default: 300ms
doubleClickOnly?: boolean; // Default: false
}interface UseDoubleClickReturn {
onClick: (event: MouseEvent) => void;
onTouchEnd: (event: TouchEvent) => void;
}import { useDoubleClick } from '@/hooks/use-double-click';
function MyComponent() {
const handlers = useDoubleClick({
onSingleClick: () => {
console.log('Single click detected');
},
onDoubleClick: () => {
console.log('Double click detected');
},
});
return (
<div {...handlers}>
Click me once or twice!
</div>
);
}import { useDoubleClick } from '@/hooks/use-double-click';
function MyComponent() {
const handlers = useDoubleClick({
onDoubleClick: () => {
console.log('Double click detected');
},
doubleClickOnly: true, // Ignore single clicks
});
return (
<div {...handlers}>
Double-click me!
</div>
);
}import { useDoubleClick } from '@/hooks/use-double-click';
function MyComponent() {
const handlers = useDoubleClick({
onSingleClick: () => {
console.log('Single click');
},
onDoubleClick: () => {
console.log('Double click');
},
delay: 500, // 500ms window for double-click detection
});
return (
<button {...handlers}>
Click with custom timing
</button>
);
}The hook uses native touchend events for better responsiveness on iOS:
onTouchEnd: (event: TouchEvent) => {
event.preventDefault(); // Prevent ghost clicks
handleClick(event);
}iOS can fire both touch and click events for the same user interaction. The hook prevents this:
if (event.type === 'click') {
const now = Date.now();
if (now - lastTouchTimeRef.current < 500) {
event.preventDefault();
return; // Ignore ghost click
}
}Uses Date.now() for precise timing measurements instead of relying on event timestamps:
lastTouchTimeRef.current = Date.now();Comprehensive tests are available in tests/use-double-click.test.ts covering:
- ✅ Double-click detection
- ✅ Single-click detection with delay
- ✅ Double-click only mode
- ✅ Custom delay timing
- ✅ iOS touch events
- ✅ Ghost click prevention
- ✅ Multiple sequential double-clicks
- ✅ Edge cases (3+ rapid clicks)
Run tests with:
pnpm vitest --run tests/use-double-click.test.tsA complete interactive demo is available at:
- Component:
src/components/examples/DoubleClickDemo.tsx - Route: Create a route that renders
<DoubleClickDemo />to see it in action
The demo showcases:
- Standard single + double click behavior
- Double-click only mode
- Click counters
- Visual feedback
- iOS-specific optimizations explanation
| Platform | Supported | Notes |
|---|---|---|
| iOS 12+ | ✅ | Fully tested with touch events |
| iOS 18+ | ✅ | All features working |
| Android | ✅ | Touch events supported |
| Desktop | ✅ | Standard mouse events |
| Safari | ✅ | Optimized for iOS Safari |
| Chrome | ✅ | All platforms |
| Firefox | ✅ | All platforms |
The hook uses correct browser types:
numberforsetTimeoutreturn value (notNodeJS.Timeout)window.setTimeoutexplicitly used for browser environment- Proper TypeScript event types (
MouseEvent,TouchEvent)
- Clears timers properly to prevent memory leaks
- Resets state after each interaction
- No memory retained between hook re-renders
- Minimal re-renders using
useCallbackanduseRef - No state updates for internal timing logic
- Efficient event handling
If you're currently using native double-click:
// Before (unreliable on iOS)
<div onDoubleClick={handleDoubleClick}>
Click me
</div>
// After (reliable on all platforms)
import { useDoubleClick } from '@/hooks/use-double-click';
const handlers = useDoubleClick({
onDoubleClick: handleDoubleClick,
});
<div {...handlers}>
Click me
</div>Solution: Increase the delay option:
const handlers = useDoubleClick({
onDoubleClick: handleDoubleClick,
delay: 400, // Increase from default 300ms
});Solution: Decrease the delay option:
const handlers = useDoubleClick({
onDoubleClick: handleDoubleClick,
delay: 200, // Decrease from default 300ms
});Solution: This is expected behavior. Desktop uses mouse events. The hook automatically handles both.
When modifying this hook:
- Ensure all tests pass:
pnpm vitest --run tests/use-double-click.test.ts - Test on actual iOS devices (iOS 12+)
- Verify no regressions on Android and Desktop
- Update documentation if API changes
This hook is part of the project and follows the same license terms.