A long-press — hold a button for a moment to trigger a secondary action — is everywhere on touch UIs: press-and-hold to preview, to enter selection mode, to show a context menu. The gesture is "the pointer stayed down for at least N milliseconds without leaving." useLongPress implements it as a timer: start counting on press, fire the callback if the timer completes, and cancel the moment the pointer lifts or leaves early.
Implement useLongPress(callback, { delay = 400 }). Return an object of event handlers — onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd — that you spread onto an element. A press starts the timer; reaching delay fires callback(event); an early release or leave cancels it.
function useLongPress(callback, { delay = 400 }) {
// returns { onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd }
}
const handlers = useLongPress(() => openContextMenu(), { delay: 500 });
<button {...handlers}>Hold me</button>
// held 500ms -> menu opens; released at 300ms -> nothing
const bind = useLongPress((e) => select(e), { delay: 400 });
<Card {...bind} />; // press-and-hold to enter selection mode
onMouseDown/onTouchStart schedule a setTimeout(delay); the timeout firing calls callback.onMouseUp, onMouseLeave, and onTouchEnd clear the pending timer so a quick tap doesn't trigger.useRef.You'll start a setTimeout on press and store its id in a ref, fire the callback when it completes, and clear it from a shared cancel handler wired to every "press ended" event.
A long-press is a race between a clock and the user's finger. On press you start a timer for delay ms; if it finishes first, that's a long-press — fire the callback. If the user lifts or drags off the element first, cancel the timer so nothing happens (that was just a tap). The whole implementation is "start a timer, clear a timer," but the timer id has to live somewhere both the start and cancel handlers can see and that survives re-renders — which is exactly a ref.
Two handlers over one ref. start (bound to mouse-down and touch-start) schedules a timeout and stashes its id in a ref. cancel (bound to mouse-up, mouse-leave, and touch-end) reads that ref and clears the timeout if one is pending. If the timeout instead runs to completion, it invokes the callback and nulls the ref. So every "press began" event points at start, every "press ended or interrupted" event points at cancel, and the ref is the single source of truth for "is a press being timed right now?"
The naive version keeps the timer id in a local variable:
function useLongPressNaive(callback, delay = 400) {
let timer = null; // reset to null on EVERY render
const start = () => {
timer = setTimeout(callback, delay);
};
const cancel = () => clearTimeout(timer);
return { onMouseDown: start, onMouseUp: cancel };
}
timer is a local, re-created as null on every render. The start and cancel returned to your element close over the timer from the render they were built in — and if the component re-renders between press and release (common, since firing state changes things), cancel sees a different timer variable than the one start set, so clearTimeout clears nothing and a "cancelled" press still fires. The timer id must persist across renders: a ref.
const { useCallback, useRef } = require('react');
function useLongPress(callback, { delay = 400 } = {}) {
const timeout = useRef(null);
const start = useCallback(
(event) => {
timeout.current = setTimeout(() => {
callback(event); // long-press completed
timeout.current = null; // done; nothing to cancel
}, delay);
},
[callback, delay],
);
const cancel = useCallback(() => {
if (timeout.current) {
clearTimeout(timeout.current); // released/left early
timeout.current = null;
}
}, []);
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}
module.exports = { useLongPress };
timeout is a ref, so the id set in start is the same id cancel reads, no matter how many renders happen in between. start schedules the timeout, capturing the event to forward to callback; when it completes it fires the callback and nulls the ref (so a later cancel is a harmless no-op and the press can't fire twice). cancel clears the pending timeout only if one exists. Wiring start to both mouse-down and touch-start, and cancel to up/leave/end, makes it work for mouse and touch with one code path. start is memoized on callback/delay so it's stable while those are.
Spread the handlers on a button, delay = 400. First the user holds for 500ms, then later taps for 100ms:
start(e) runs timeout.current = setTimeout(…, 400).callback(e) runs (menu opens), timeout.current = null.cancel runs; timeout.current is already null, so it's a no-op. The press fired exactly once.start schedules a new timeout.cancel runs; timeout.current is set, so clearTimeout cancels it and nulls the ref. The 400ms timeout never fires — a tap, not a long-press.onMouseLeave (and touch move/cancel in production) to cancel.cancel (or a stray) can't act on a stale id.onStart/onFinish/onCancel callbacks — richer libraries emit lifecycle events so the UI can show a "hold progress" ring.onPointerDown/onPointerUp unifies mouse, touch, and pen into a single set of handlers, replacing the mouse+touch pair.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A long-press — hold a button for a moment to trigger a secondary action — is everywhere on touch UIs: press-and-hold to preview, to enter selection mode, to show a context menu. The gesture is "the pointer stayed down for at least N milliseconds without leaving." useLongPress implements it as a timer: start counting on press, fire the callback if the timer completes, and cancel the moment the pointer lifts or leaves early.
Implement useLongPress(callback, { delay = 400 }). Return an object of event handlers — onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd — that you spread onto an element. A press starts the timer; reaching delay fires callback(event); an early release or leave cancels it.
function useLongPress(callback, { delay = 400 }) {
// returns { onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd }
}
const handlers = useLongPress(() => openContextMenu(), { delay: 500 });
<button {...handlers}>Hold me</button>
// held 500ms -> menu opens; released at 300ms -> nothing
const bind = useLongPress((e) => select(e), { delay: 400 });
<Card {...bind} />; // press-and-hold to enter selection mode
onMouseDown/onTouchStart schedule a setTimeout(delay); the timeout firing calls callback.onMouseUp, onMouseLeave, and onTouchEnd clear the pending timer so a quick tap doesn't trigger.useRef.You'll start a setTimeout on press and store its id in a ref, fire the callback when it completes, and clear it from a shared cancel handler wired to every "press ended" event.
A long-press is a race between a clock and the user's finger. On press you start a timer for delay ms; if it finishes first, that's a long-press — fire the callback. If the user lifts or drags off the element first, cancel the timer so nothing happens (that was just a tap). The whole implementation is "start a timer, clear a timer," but the timer id has to live somewhere both the start and cancel handlers can see and that survives re-renders — which is exactly a ref.
Two handlers over one ref. start (bound to mouse-down and touch-start) schedules a timeout and stashes its id in a ref. cancel (bound to mouse-up, mouse-leave, and touch-end) reads that ref and clears the timeout if one is pending. If the timeout instead runs to completion, it invokes the callback and nulls the ref. So every "press began" event points at start, every "press ended or interrupted" event points at cancel, and the ref is the single source of truth for "is a press being timed right now?"
The naive version keeps the timer id in a local variable:
function useLongPressNaive(callback, delay = 400) {
let timer = null; // reset to null on EVERY render
const start = () => {
timer = setTimeout(callback, delay);
};
const cancel = () => clearTimeout(timer);
return { onMouseDown: start, onMouseUp: cancel };
}
timer is a local, re-created as null on every render. The start and cancel returned to your element close over the timer from the render they were built in — and if the component re-renders between press and release (common, since firing state changes things), cancel sees a different timer variable than the one start set, so clearTimeout clears nothing and a "cancelled" press still fires. The timer id must persist across renders: a ref.
const { useCallback, useRef } = require('react');
function useLongPress(callback, { delay = 400 } = {}) {
const timeout = useRef(null);
const start = useCallback(
(event) => {
timeout.current = setTimeout(() => {
callback(event); // long-press completed
timeout.current = null; // done; nothing to cancel
}, delay);
},
[callback, delay],
);
const cancel = useCallback(() => {
if (timeout.current) {
clearTimeout(timeout.current); // released/left early
timeout.current = null;
}
}, []);
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}
module.exports = { useLongPress };
timeout is a ref, so the id set in start is the same id cancel reads, no matter how many renders happen in between. start schedules the timeout, capturing the event to forward to callback; when it completes it fires the callback and nulls the ref (so a later cancel is a harmless no-op and the press can't fire twice). cancel clears the pending timeout only if one exists. Wiring start to both mouse-down and touch-start, and cancel to up/leave/end, makes it work for mouse and touch with one code path. start is memoized on callback/delay so it's stable while those are.
Spread the handlers on a button, delay = 400. First the user holds for 500ms, then later taps for 100ms:
start(e) runs timeout.current = setTimeout(…, 400).callback(e) runs (menu opens), timeout.current = null.cancel runs; timeout.current is already null, so it's a no-op. The press fired exactly once.start schedules a new timeout.cancel runs; timeout.current is set, so clearTimeout cancels it and nulls the ref. The 400ms timeout never fires — a tap, not a long-press.onMouseLeave (and touch move/cancel in production) to cancel.cancel (or a stray) can't act on a stale id.onStart/onFinish/onCancel callbacks — richer libraries emit lifecycle events so the UI can show a "hold progress" ring.onPointerDown/onPointerUp unifies mouse, touch, and pen into a single set of handlers, replacing the mouse+touch pair.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.