66

Is there a way to add long press event in react-web application?

I have list of addresses. On long press on any address, I want to fire event to delete that address followed by a confirm box.

Brian Tompsett - 汤莱恩
  • 5,753
  • 72
  • 57
  • 129
Binit Ghetiya
  • 1,919
  • 2
  • 21
  • 31
  • 1
    Here's a similar question with a great answer, in case you missed it https://stackoverflow.com/questions/2625210/long-press-in-javascript – raksheetbhat Jan 01 '18 at 11:43

16 Answers16

125

I've created a codesandbox with a hook to handle long press and click. Basically, on mouse down, touch start events, a timer is created with setTimeout. When the provided time elapses, it triggers long press. On mouse up, mouse leave, touchend, etc, the timer is cleared.

useLongPress.js

import { useCallback, useRef, useState } from "react";

const useLongPress = (
    onLongPress,
    onClick,
    { shouldPreventDefault = true, delay = 300 } = {}
    ) => {
    const [longPressTriggered, setLongPressTriggered] = useState(false);
    const timeout = useRef();
    const target = useRef();

    const start = useCallback(
        event => {
            if (shouldPreventDefault && event.target) {
                    event.target.addEventListener("touchend", preventDefault, {
                    passive: false
                });
                target.current = event.target;
            }
            timeout.current = setTimeout(() => {
                onLongPress(event);
                setLongPressTriggered(true);
            }, delay);
        },
        [onLongPress, delay, shouldPreventDefault]
    );

    const clear = useCallback(
        (event, shouldTriggerClick = true) => {
            timeout.current && clearTimeout(timeout.current);
            shouldTriggerClick && !longPressTriggered && onClick();
            setLongPressTriggered(false);
            if (shouldPreventDefault && target.current) {
                target.current.removeEventListener("touchend", preventDefault);
            }
        },
        [shouldPreventDefault, onClick, longPressTriggered]
    );

    return {
        onMouseDown: e => start(e),
        onTouchStart: e => start(e),
        onMouseUp: e => clear(e),
        onMouseLeave: e => clear(e, false),
        onTouchEnd: e => clear(e)
    };
};

const isTouchEvent = event => {
return "touches" in event;
};

const preventDefault = event => {
if (!isTouchEvent(event)) return;

if (event.touches.length < 2 && event.preventDefault) {
    event.preventDefault();
}
};

export default useLongPress;

To use the hook, App.js

import useLongPress from "./useLongPress";

export default function App() {

    const onLongPress = () => {
        console.log('longpress is triggered');
    };

    const onClick = () => {
        console.log('click is triggered')
    }

    const defaultOptions = {
        shouldPreventDefault: true,
        delay: 500,
    };
    const longPressEvent = useLongPress(onLongPress, onClick, defaultOptions);

    return (
        <div className="App">
            <button {...longPressEvent}>use  Loooong  Press</button>
        </div>
    );
}

Older answer for class components:

You can use MouseDown, MouseUp, TouchStart, TouchEnd events to control timers that can act as a long press event. Check out the code below

class App extends Component {
  constructor() {
    super()
    this.handleButtonPress = this.handleButtonPress.bind(this)
    this.handleButtonRelease = this.handleButtonRelease.bind(this)
  }
  handleButtonPress () {
    this.buttonPressTimer = setTimeout(() => alert('long press activated'), 1500);
  }
  
  handleButtonRelease () {
    clearTimeout(this.buttonPressTimer);
  }

  render() {
    return (
      <div 
          onTouchStart={this.handleButtonPress} 
          onTouchEnd={this.handleButtonRelease} 
          onMouseDown={this.handleButtonPress} 
          onMouseUp={this.handleButtonRelease} 
          onMouseLeave={this.handleButtonRelease}>
        Button
      </div>
    );
  }
}
sudo bangbang
  • 27,127
  • 11
  • 75
  • 77
  • 3
    You need also to add onMouseLeave={this.handleButtonRelease} – Johniak Jan 17 '19 at 19:39
  • 12
    What if I also want to handle a normal mouse click at the same time? (If the answer is going to be long, please let me know. I will raise a new question) – Anthony Kong Feb 05 '19 at 23:20
  • 1
    @AnthonyKong, I've updated the answer with hooks including a solution to handle click as well. – sudo bangbang Jul 06 '20 at 14:25
  • 1
    This is great! FWIW written as of right now the onClick does not pass the event object. You could say that the event object is a "mouseup" event and so onclick shouldn't get it, but in my case I needed it so I tweaked this to pass the event. – user3788955 Oct 28 '20 at 20:43
  • ahh many thaanks. In my case I have a list (similar to messaging list) and click should not trigger when scrolling through the list. Not sure if really efficient but I add scrolllListener e.g document.addEventListener('scroll', onScroll);, and do the conditionals to the callbacks. :D thanks a lot @sudobangbang. I feel honored to be 69th upvote – keysl Jan 30 '21 at 12:58
  • 1
    In a callback with event parameter (`const onClick = (e) => { ...`), `e` is only defined in `onLongPress` and `undefined` in `onClick`. How can I pass arguments from multiple buttons sharing the same `{...longPressEvent}`? – steffen Apr 17 '21 at 15:58
  • 2
    Just a quick addition, as steffen mentioned, e is undefined for onClick, so... inside useLongPress, there's a line: "shouldTriggerClick && !longPressTriggered && onClick();" I added "event" (without the quotes of course) inside the "onClick()". Meaning, it should look like this: "shouldTriggerClick && !longPressTriggered && onClick(event);" . Now you'll have access to e even on a single click (e will be defined). – Tal Kohavy Aug 08 '21 at 14:18
  • if you store `longPressTriggered` bool in a useRef() (ie. `longPressTriggered.current = true`), instead of useState, it will prevent unnecessary re-renders... – orszaczky Aug 10 '21 at 07:47
  • How can I pass an argument to `{...longPressEvent}` ? – KaMZaTa Sep 05 '21 at 22:57
  • I'd need to pass an argument to `onCancel` callback through `{..longPressEvent}`. Is there a way? – KaMZaTa Sep 05 '21 at 23:32
  • Unfortunately it doesn't work properly on mobile (chrome browser), u can see it in sandbox example as well - onLongPress event is fired only once, no matter how long being is pressed. I've found properly working example in other answer – MagyaDEV Jan 24 '22 at 11:30
  • It should be noted that you rely on functions as dependencies (`onLongPress` and `onClick`), so these functions should be memoized/wrappend in `useCallback()` at the caller's side if I'm not mistaken. – NotX Nov 03 '22 at 20:04
  • The long press and onclick don't work if you make a state update in them – 55 Cancri Jan 14 '23 at 12:50
80

With hooks in react 16.8 you could rewrite class with functions and hooks.

import { useState, useEffect } from 'react';

export default function useLongPress(callback = () => {}, ms = 300) {
  const [startLongPress, setStartLongPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startLongPress) {
      timerId = setTimeout(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [callback, ms, startLongPress]);

  return {
    onMouseDown: () => setStartLongPress(true),
    onMouseUp: () => setStartLongPress(false),
    onMouseLeave: () => setStartLongPress(false),
    onTouchStart: () => setStartLongPress(true),
    onTouchEnd: () => setStartLongPress(false),
  };
}
import useLongPress from './useLongPress';

function MyComponent (props) {
  const backspaceLongPress = useLongPress(props.longPressBackspaceCallback, 500);

  return (
    <Page>
      <Button {...backspaceLongPress}>
        Click me
      </Button>
    </Page>
  );
};

David
  • 1,094
  • 9
  • 9
  • 5
    Excellent with an up to date answer! Good stuff. – mackwerk Feb 22 '19 at 08:06
  • 3
    Great way to get it done! How can i pass a callback with arguments? – Meir Keller Mar 22 '19 at 21:34
  • 1
    The `clearTimeout` inside `else` doesn't do anything - `timerId` will always be undefined, since it was just declared and never assigned (we're running the callback from scratch with each effect trigger). But you don't really need it either, the cleanup `clearTimeout` is enough. – Michal Kurz Jan 25 '20 at 15:26
  • 4
    Caution! This approach triggers unnecessary re-renders every time user triggers an action. If you compare this to https://stackoverflow.com/a/48057286/329879 (one that uses a class) - you'll see that class component does not trigger unnecessary re-renders. This can be improved by using useRef instead for the timer. And the timer can be used to tracked if button was pressed or not. – SublimeYe Apr 03 '20 at 21:10
  • @SublimeYe you are right. But I am not sure how to change it with useRef as you mentioned :( – Pirastrino Apr 14 '20 at 20:00
  • One tiny thing I added (to prevent the dialog I am showing onLongPress appearing indeterminate amount of times) is `setStartLongPress` to `false` in `useEffect` before calling `callback()`. – lidkxx Apr 09 '21 at 09:22
  • Works like a charm, while top voted answer is not. Upvote! – MagyaDEV Jan 24 '22 at 11:31
25

Nice hook! But I would like make a small improvement. Using useCallback to wrap event handlers. This ensures these will not changed on every render.

import { useState, useEffect, useCallback } from 'react';

export default function useLongPress(callback = () => {}, ms = 300) {
  const [startLongPress, setStartLongPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startLongPress) {
      timerId = setTimeout(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [callback, ms, startLongPress]);

  const start = useCallback(() => {
    setStartLongPress(true);
  }, []);
  const stop = useCallback(() => {
    setStartLongPress(false);
  }, []);

  return {
    onMouseDown: start,
    onMouseUp: stop,
    onMouseLeave: stop,
    onTouchStart: start,
    onTouchEnd: stop,
  };
}
Huong Nguyen
  • 676
  • 7
  • 6
  • 3
    Good answer, one small thing is though, `stop` and `start` both call `setStartLongPress(true)`, shouldn't they be inverted? – Yoav Hortman May 06 '19 at 13:29
  • 1
    @YoavHortman Thank you for pointing out my mistake. I fixed it. The `stop` function should `setStartLongPress(false)` :) – Huong Nguyen May 07 '19 at 14:17
  • how do I use this in a component? – Kuza Grave Dec 13 '19 at 00:37
  • You can see the usage inside David's answer. – Michal Kurz Jan 25 '20 at 15:28
  • 1
    I pointed this out with David's answer and the same holds true here: The `clearTimeout` inside `else` doesn't do anything - `timerId` will always be undefined, since it was just declared and never assigned (we're running the callback from scratch with each effect trigger). But you don't really need it either, the cleanup `clearTimeout` is enough – Michal Kurz Jan 25 '20 at 15:29
  • seems this will continuously invoke the call back. perhaps something like: timerId = setTimeout(() => { callback(); stop() }, ms) – nihlton Apr 26 '20 at 22:25
  • should probably also include onTouchMove: stop – nihlton Apr 26 '20 at 22:43
  • maybe I am missing something here, but are the wrapper `useCallback`s actually necessary? – idrisadetunmbi Jun 26 '20 at 05:14
  • useCallback is actually pretty slow, it often costs less to recreate the handler on each render. This is a good reading on the subject: https://kentcdodds.com/blog/usememo-and-usecallback – Burgito Aug 30 '21 at 13:32
  • your `start` and `stop` in useCallback, if you really want to memoize it - add to the deps array `setStartLongPress` – Artem Fedotov May 27 '22 at 11:38
12

Based on @Sublime me comment above about avoiding multiple re-renders, my version doesn't use anything that triggers renders:

export function useLongPress({
  onClick = () => {},
  onLongPress = () => {},
  ms = 300,
} = {}) {
  const timerRef = useRef(false);
  const eventRef = useRef({});

  const callback = useCallback(() => {
    onLongPress(eventRef.current);
    eventRef.current = {};
    timerRef.current = false;
  }, [onLongPress]);

  const start = useCallback(
    (ev) => {
      ev.persist();
      eventRef.current = ev;
      timerRef.current = setTimeout(callback, ms);
    },
    [callback, ms]
  );

  const stop = useCallback(
    (ev) => {
      ev.persist();
      eventRef.current = ev;
      if (timerRef.current) {
        clearTimeout(timerRef.current);
        onClick(eventRef.current);
        timerRef.current = false;
        eventRef.current = {};
      }
    },
    [onClick]
  );

  return useMemo(
    () => ({
      onMouseDown: start,
      onMouseUp: stop,
      onMouseLeave: stop,
      onTouchStart: start,
      onTouchEnd: stop,
    }),
    [start, stop]
  );
}

It also provides both onLongPress and onClick and passes on the event object received.

Usage is mostly as described earlier, except arguments are now passed in an object, all are optional:

  const longPressProps = useLongPress({
    onClick: (ev) => console.log('on click', ev.button, ev.shiftKey),
    onLongPress: (ev) => console.log('on long press', ev.button, ev.shiftKey),
  });

// and later:
  return (<button {...longPressProps}>click me</button>);
Devasatyam
  • 616
  • 6
  • 15
  • The `stop` callback calls the `onClick` callback which would be fine if it wasn't used for `onMouseLeave`. `onMouseLeave` should not trigger `onClick`. – Daniel Wood Jan 24 '23 at 10:37
9

Here is a Typescript version of the most popular answer, in case it is useful to anybody:

(it also fixes a problem with accessing event properties within the delegated event on the timeOut by using e.persist() and cloning the event)

useLongPress.ts

import { useCallback, useRef, useState } from "react";
  
function preventDefault(e: Event) {
  if ( !isTouchEvent(e) ) return;
  
  if (e.touches.length < 2 && e.preventDefault) {
    e.preventDefault();
  }
};

export function isTouchEvent(e: Event): e is TouchEvent {
  return e && "touches" in e;
};

interface PressHandlers<T> {
  onLongPress: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,
  onClick?: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,
}

interface Options {
  delay?: number,
  shouldPreventDefault?: boolean
}

export default function useLongPress<T>(
  { onLongPress, onClick }: PressHandlers<T>,
  { delay = 300, shouldPreventDefault = true }
  : Options
  = {}
) {
  const [longPressTriggered, setLongPressTriggered] = useState(false);
  const timeout = useRef<NodeJS.Timeout>();
  const target = useRef<EventTarget>();

  const start = useCallback(
    (e: React.MouseEvent<T> | React.TouchEvent<T>) => {
      e.persist();
      const clonedEvent = {...e};
      
      if (shouldPreventDefault && e.target) {
        e.target.addEventListener(
          "touchend",
          preventDefault,
          { passive: false }
        );
        target.current = e.target;
      }

      timeout.current = setTimeout(() => {
        onLongPress(clonedEvent);
        setLongPressTriggered(true);
      }, delay);
    },
    [onLongPress, delay, shouldPreventDefault]
  );

  const clear = useCallback((
      e: React.MouseEvent<T> | React.TouchEvent<T>,
      shouldTriggerClick = true
    ) => {
      timeout.current && clearTimeout(timeout.current);
      shouldTriggerClick && !longPressTriggered && onClick?.(e);

      setLongPressTriggered(false);

      if (shouldPreventDefault && target.current) {
        target.current.removeEventListener("touchend", preventDefault);
      }
    },
    [shouldPreventDefault, onClick, longPressTriggered]
  );

  return {
    onMouseDown: (e: React.MouseEvent<T>) => start(e),
    onTouchStart: (e: React.TouchEvent<T>) => start(e),
    onMouseUp: (e: React.MouseEvent<T>) => clear(e),
    onMouseLeave: (e: React.MouseEvent<T>) => clear(e, false),
    onTouchEnd: (e: React.TouchEvent<T>) => clear(e)
  };
};
Sunyatasattva
  • 5,619
  • 3
  • 27
  • 37
  • can u give an example on how to use useLongPress? your typescript version requires args to be passed into the hook, while th js solution above urs doesnt. I cant seem to understand what im meant to pass in – samplecode3300 Apr 06 '23 at 02:11
  • The only thing that you need to pass to `useLongPress` are the callback handlers, which then receive the `event` objects. [Here](https://github.com/sunyatasattva/rainbow-spectre/blob/f569e918317f8c329f75ce17fc560658bccc7a30/src/components/Core.tsx#L38) are some examples of this hook used in a real project. Hope that's useful! – Sunyatasattva Apr 12 '23 at 07:13
  • 1
    Note that [`e.persist()` is a no-op since React v17](https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#no-event-pooling), but apparently is [still relevant in React-native](https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#no-event-pooling). – kca Jun 23 '23 at 11:07
7

Generic hook that avoids re-renders

This is something I'm using in production, inspired by the original answers. If there's a bug below, well I guess I have a bug in production! ‍♂️

Usage

I wanted to keep the hook a bit more concise and allow composability if the implementation calls for it (e.g.: adding fast input vs slow input, rather than a single callback).

const [onStart, onEnd] = useLongPress(() => alert('Old School Alert'), 1000);

return (
  <button
    type="button"
    onTouchStart={onStart}
    onTouchEnd={onEnd}
  >
    Hold Me (Touch Only)
  </button>
)

Implementation

It's a simpler implementation than it seems. Just a lot more lines of comments.

I added a bunch of comments so if you do copy/paste this into your codebase, your colleagues can understand it better during PR.

import {useCallback, useRef} from 'react';

export default function useLongPress(
  // callback that is invoked at the specified duration or `onEndLongPress`
  callback : () => any,
  // long press duration in milliseconds
  ms = 300
) {
  // used to persist the timer state
  // non zero values means the value has never been fired before
  const timerRef = useRef<number>(0);

  // clear timed callback
  const endTimer = () => {
    clearTimeout(timerRef.current || 0);
    timerRef.current = 0;
  };

  // init timer
  const onStartLongPress = useCallback((e) => {
    // stop any previously set timers
    endTimer();

    // set new timeout
    timerRef.current = window.setTimeout(() => {
      callback();
      endTimer();
    }, ms);
  }, [callback, ms]);

  // determine to end timer early and invoke the callback or do nothing
  const onEndLongPress = useCallback(() => {
    // run the callback fn the timer hasn't gone off yet (non zero)
    if (timerRef.current) {
      endTimer();
      callback();
    }
  }, [callback]);

  return [onStartLongPress, onEndLongPress, endTimer];
}

Example

Using 500ms setting in the example. The spontaneous circle in the GIF shows when I'm pressing down.

example

Matt Lo
  • 5,442
  • 1
  • 21
  • 21
4

Here's a component that provides onClick and onHold events - adapt as needed...

CodeSandbox: https://codesandbox.io/s/hold-press-event-r8q9w

Usage:

import React from 'react'
import Holdable from './holdable'

function App() {

  function onClick(evt) {
    alert('click ' + evt.currentTarget.id)
  }

  function onHold(evt) {
    alert('hold ' + evt.currentTarget.id)
  }

  const ids = 'Label1,Label2,Label3'.split(',')

  return (
    <div className="App">
      {ids.map(id => (
        <Holdable
          onClick={onClick}
          onHold={onHold}
          id={id}
          key={id}
        >
          {id}
        </Holdable>
      ))}
    </div>
  )
}

holdable.jsx:

import React from 'react'

const holdTime = 500 // ms
const holdDistance = 3**2 // pixels squared

export default function Holdable({id, onClick, onHold, children}) {

  const [timer, setTimer] = React.useState(null)
  const [pos, setPos] = React.useState([0,0])

  function onPointerDown(evt) {
    setPos([evt.clientX, evt.clientY]) // save position for later
    const event = { ...evt } // convert synthetic event to real object
    const timeoutId = window.setTimeout(timesup.bind(null, event), holdTime)
    setTimer(timeoutId)
  }

  function onPointerUp(evt) {
    if (timer) {
      window.clearTimeout(timer)
      setTimer(null)
      onClick(evt)
    }
  }

  function onPointerMove(evt) {
    // cancel hold operation if moved too much
    if (timer) {
      const d = (evt.clientX - pos[0])**2 + (evt.clientY - pos[1])**2
      if (d > holdDistance) {
        setTimer(null)  
        window.clearTimeout(timer)
      }
    }
  }

  function timesup(evt) {
    setTimer(null)
    onHold(evt)
  }

  return (
    <div
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerMove={onPointerMove}
      id={id}
    >
      {children}
    </div>
  )
}

Note: this doesn't work with Safari yet - pointer events are coming in v13 though - https://caniuse.com/#feat=pointer

Brian Burns
  • 20,575
  • 8
  • 83
  • 77
4

This is the simplest and best solution I could made on my own.

  • This way you don't need to pass the click event
  • Click event still working
  • The hook returns a function instead of the events itselves , then you can use it within a loop or conditionally and pass different callbacks to each element.

useLongPress.js

export default function useLongPress() {
  return function (callback) {
    let timeout;
    let preventClick = false;

    function start() {
      timeout = setTimeout(() => {
        preventClick = true;
        callback();
      }, 300);
    }

    function clear() {
      timeout && clearTimeout(timeout);
      preventClick = false;
    }

    function clickCaptureHandler(e) {
      if (preventClick) {
        e.stopPropagation();
        preventClick = false;
      }
    }

    return {
      onMouseDown: start,
      onTouchStart: start,
      onMouseUp: clear,
      onMouseLeave: clear,
      onTouchMove: clear,
      onTouchEnd: clear,
      onClickCapture: clickCaptureHandler
    };
  }
}

Usage:

import useLongPress from './useLongPress';

export default function MyComponent(){
  const onLongPress = useLongPress();
  const buttons = ['button one', 'button two', 'button three'];

  return (
    buttons.map(text => 
      <button
        onClick={() => console.log('click still working')}
        {...onLongPress(() => console.log('long press worked for ' + text))}
      >
      {text}
      </button>
    )
  )
}
Maycow Moura
  • 6,471
  • 2
  • 22
  • 19
  • i ended up using this solution, but I had to remove preventClick = false; from function clear() as single click always fired after long press, not sure why. – Stanislau Buzunko Nov 22 '22 at 06:29
2

Brian's solution allows you to pass params to the children which I think is not doable with the Hook. Still, if I may suggest a bit cleaner solution for most common case where you want to add onHold behavior to a single component and you also want to be able to change the onHold timeout.

Material-UI example with Chip component:

'use strict';

const {
  Chip
} = MaterialUI

function ChipHoldable({
  onClick = () => {},
  onHold = () => {},
  hold = 500,
  ...props
}) {
  const [timer, setTimer] = React.useState(null);

  function onPointerDown(evt) {
    const event = { ...evt
    }; // convert synthetic event to real object
    const timeoutId = window.setTimeout(timesup.bind(null, event), hold);
    setTimer(timeoutId);
  }

  function onPointerUp(evt) {
    if (timer) {
      window.clearTimeout(timer);
      setTimer(null);
      onClick(evt);
    }
  }

  const onContextMenu = e => e.preventDefault();

  const preventDefault = e => e.preventDefault(); // so that ripple effect would be triggered

  function timesup(evt) {
    setTimer(null);
    onHold(evt);
  }

  return React.createElement(Chip, {
    onPointerUp,
    onPointerDown,
    onContextMenu,
    onClick: preventDefault,
    ...props
  });
}

const App = () =>  <div> {[1,2,3,4].map(i => < ChipHoldable style={{margin:"10px"}}label = {`chip${i}`}
    onClick = {
      () => console.log(`chip ${i} clicked`)
    }
    onHold = {
      () => console.log(`chip ${i} long pressed`)
    }
    />)}
    </div>


ReactDOM.render( <App/>, document.querySelector('#root'));
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8" />
</head>

<body>
  <div id="root"></div>
  <script src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
  <script src="https://unpkg.com/@material-ui/core@latest/umd/material-ui.development.js"></script>
</body>

</html>
radulle
  • 1,437
  • 13
  • 19
2

After much deliberation, looking at other answers, and adding newer features, I think I now have a solid, if not the best, React long press implementation yet. Here are the highlights:

  • Only one fn needs to be passed that will be used for both onClick and onLongPress, though they can still be individually defined
  • Stores the fn in a ref so you can do state updates without having to worry about the fn going stale and not getting the latest react state
  • Allows for a static or dynamic delay so the longPress fn can start to execute faster or slower depending on how long the button has been held
  • Written in typescript
// useInterval.ts
import React from "react";

export default function useInterval(callback: any, delay: number | null) {
  const savedCallback = React.useRef<any>();

  React.useEffect(() => {
    savedCallback.current = callback;
  });

  React.useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}


// useLongPress.ts
import React from "react";
import useInterval from "./use-interval";

type Fn<T> = (
  e: React.MouseEvent<T, MouseEvent>,
  pressedTimeElapsedInMs: number
) => void;

type Opts<T extends HTMLElement> = {
  shouldPreventDefault?: boolean;
  delay?: number | ((pressedTimeElapsedInMs: number) => number);
  onClick?: boolean | Fn<T>;
};

/**
 * useLongPress hook that handles onClick and longPress events.
 * if you dont pass an onClick fn, the longPress fn will be for onClick.
 * the delay can be a number or a function that recieves how long the button has been pressed.
 * This value can be used to calculate a dynamic value.
 * The onClick and longPress fns will receive the click or touch event as the first parameter,
 * and how long the button has been pressed as the second parameter.
 * @param onLongPress
 * @param opts
 * @returns
 */
export default function useLongPress<T extends HTMLElement>(
  onLongPress: Fn<T>,
  opts: Opts<T> = {}
) {
  const {
    // default onClick to onLongPress if no onClick fn is provided
    onClick = onLongPress,
    shouldPreventDefault = true,
    delay: initialDelay = 300,
  } = opts;

  // hold duration in ms
  const [holdDuration, setHoldDuration] = React.useState(0);
  const [longPressTriggered, setLongPressTriggered] = React.useState(false);
  const [delay, setDelay] = React.useState(0);

  const target = React.useRef<EventTarget | null>(null);
  // store the click or touch event globally so the fn function can pass it to longPress
  const evt = React.useRef<any | null>(null);

  // store the latest onLongPress and onClick fns here to prevent them being stale when used
  const longPressRef = React.useRef<Fn<T>>();
  const clickRef = React.useRef<Fn<T>>();

  // update the onClick and onLongPress fns everytime they change
  React.useEffect(() => {
    longPressRef.current = onLongPress;
    // if false is passed as onClick option, use onLongPress fn in its place
    clickRef.current = typeof onClick === "boolean" ? onLongPress : onClick;
  }, [onClick, onLongPress]);

  // this fn will be called onClick and in on interval when the btn is being held down
  const fn = React.useCallback(() => {
    // call the passed in onLongPress fn, giving it the click
    //  event and the length of time the btn is being held
    longPressRef.current?.(evt.current, holdDuration);
    // get the latest delay duration by passing the current
    // hold duration if it was a fn, or just use the number
    const updatedDelay =
      typeof initialDelay === "function"
        ? initialDelay(holdDuration)
        : initialDelay;

    // update the delay if its dynamic
    setDelay(updatedDelay);
    // update how long the btn has been pressed
    setHoldDuration(holdDuration + updatedDelay);
    setLongPressTriggered(true);
  }, [initialDelay, holdDuration]);

  // start calling the fn function on an interval as the button is being held
  useInterval(fn, longPressTriggered ? delay : null);

  // this fn is called onMouseDown and onTouchStart
  const start = React.useCallback(
    (event: React.MouseEvent<T, MouseEvent> | React.TouchEvent<T>) => {
      if (shouldPreventDefault && event.target) {
        event.target.addEventListener("touchend", preventDefault, {
          passive: false,
        });
        target.current = event.target;
      }

      // globally store the click event
      evt.current = event;
      // call the fn function once, which handles the onClick
      fn();
    },
    [shouldPreventDefault, fn]
  );

  // this fn is called onMouseUp and onTouchEnd
  const clear = React.useCallback(
    (
      event: React.MouseEvent<T, MouseEvent> | React.TouchEvent<T>,
      shouldTriggerClick = true
    ) => {
      // reset how long the btn has been held down
      setHoldDuration(0);

      if (shouldTriggerClick && !longPressTriggered) {
        clickRef.current?.(
          event as React.MouseEvent<T, MouseEvent>,
          holdDuration
        );
      }

      // stop the interval
      setLongPressTriggered(false);
      // clear the globally stored click event
      evt.current = null;
      if (shouldPreventDefault && target.current) {
        target.current.removeEventListener("touchend", preventDefault);
      }
    },
    [clickRef, longPressTriggered, shouldPreventDefault, holdDuration]
  );

  return {
    onMouseDown: (e: React.MouseEvent<T, MouseEvent>) => start(e),
    onMouseUp: (e: React.MouseEvent<T, MouseEvent>) => clear(e),
    onMouseLeave: (e: React.MouseEvent<T, MouseEvent>) => clear(e, false),
    onTouchStart: (e: React.TouchEvent<T>) => start(e),
    onTouchEnd: (e: React.TouchEvent<T>) => clear(e),
  };
}

const assertTouchEvt = (event: Event | TouchEvent): event is TouchEvent => {
  return "touches" in event;
};

const preventDefault = (event: Event | TouchEvent) => {
  if (!assertTouchEvt(event)) return;

  if (event.touches.length < 2 && event.preventDefault) {
    event.preventDefault();
  }
};

Then the hook can be used in the following ways:

state update with default options

export default App() {
  const [count, setCount] = React.useState(0)

  const useIncrement = useLongPress((e, holdDurationInMs) => {
    setCount(count + 1)
  })
}

state update with a static delay and where the amount increases based on how many milliseconds the button has been held down

export default App() {
  const [count, setCount] = React.useState(0)

  const useIncrement = useLongPress((e, holdDurationInMs) => {
    if (holdDurationInMs < 1000) setCount(count + (e.metaKey || e.shiftKey ? 5 : 1))
    else if (holdDurationInMs < 3000) setCount(count + 5)
    else setCount(count + 100)
  }, {
    delay: 300
  })
}

state update with a dynamic delay that executes the function faster as the button is held down longer

export default App() {
  const [count, setCount] = React.useState(0)

  const useIncrement = useLongPress((e, holdDurationInMs) => {
    setCount(count + 1)
  }, {
       delay: (holdDurationInMs) => {
          if (holdDurationInMs < 1000) return 550;
          else if (holdDurationInMs < 3000) return 450;
          else if (holdDurationInMs < 8000) return 250;
          else return 110;
       },
    })
  }
55 Cancri
  • 1,075
  • 11
  • 23
  • 1
    Very nice answer, with proper details, cherry on top it's in type script. – Binit Ghetiya Jan 16 '23 at 08:36
  • I no longer consider this the best implementation. Please see my latest answer which uses simpler logic and totally removes the need for a longPress hook that needs to be spread and instead exposes an onLongPress handler on a button itself: https://stackoverflow.com/a/76134013/6106899 – 55 Cancri Apr 29 '23 at 00:33
1

An adaptation of David's solution: a React hook for when you want to repeatedly fire the event. It uses setInterval instead.

export function useHoldPress(callback = () => {}, ms = 300) {
  const [startHoldPress, setStartHoldPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startHoldPress) {
      timerId = setInterval(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [startHoldPress]);

  return {
    onMouseDown: () => setStartHoldPress(true),
    onMouseUp: () => setStartHoldPress(false),
    onMouseLeave: () => setStartHoldPress(false),
    onTouchStart: () => setStartHoldPress(true),
    onTouchEnd: () => setStartHoldPress(false)
  };
}
Eddy Vinck
  • 410
  • 1
  • 4
  • 16
1

Ionic React LongPress Example I use it with Ionic React, it works well.

import React, {useState}  from 'react';
import { Route, Redirect } from 'react-router';

interface MainTabsProps { }
const MainTabs: React.FC<MainTabsProps> = () => {

// timeout id  
var initial: any;

// setstate
const [start, setStart] = useState(false);

const handleButtonPress = () => {
  initial = setTimeout(() => {
    setStart(true); // start long button          
    console.log('long press button');
    }, 1500);
}

const handleButtonRelease = () => {
  setStart(false); // stop long press   
  clearTimeout(initial); // clear timeout  
  if(start===false) { // is click
    console.log('click button');
  }  
}

  return (
    <IonPage>
      <IonHeader>
        <IonTitle>Ionic React LongPress</IonTitle>
      </IonHeader>    
      <IonContent className="ion-padding">
        <IonButton expand="block"  
          onMouseDown={handleButtonPress} 
          onMouseUp={handleButtonRelease} >LongPress</IonButton>    
      </IonContent>
    </IonPage>
  );
};

export default MainTabs;
1

Just wanted to point out that hooks aren't a great solution here since you can't use them in a call back.

for example, if you wanted to add long press to a number of elements:

items.map(item => <button {...useLongPress(() => handle(item))}>{item}</button>)

gets you:

... React Hooks must be called in a React function component or a custom React Hook function

you could however use vanilla JS:

export default function longPressEvents(callback, ms = 500) {
  let timeout = null

  const start = () => timeout = setTimeout(callback, ms)
  const stop = () => timeout && window.clearTimeout(timeout)

  return callback ? {
    onTouchStart: start,
    onTouchMove: stop,
    onTouchEnd: stop,
  } : {}
}

then:

items.map(item => <button { ...longPressEvents(() => handle(item)) }>{item}</button>)

demo: https://codesandbox.io/s/long-press-hook-like-oru24?file=/src/App.js

just be aware that longPressEvents will run every render. Probably not a big deal, but something to keep in mind.

nihlton
  • 659
  • 6
  • 7
1

Type Script example make common long Press event

import { useCallback, useRef, useState } from "react";

interface Props {
    onLongPress: (e: any) => void;
    onClick: (e: any) => void;
    obj: { shouldPreventDefault: boolean, delay: number }
}

const useLongPress = (props: Props) => {
    const [longPressTriggered, setLongPressTriggered] = useState(false);
    const timeout: any = useRef();
    const target: any = useRef();

    const start = useCallback(
        event => {
            if (props.obj.shouldPreventDefault && event.target) {
                event.target.addEventListener("touchend", preventDefault, {
                    passive: false
                });
                target.current = event.target;
            }
            timeout.current = setTimeout(() => {
                props.onLongPress(event);
                setLongPressTriggered(true);
            }, props.obj.delay);
        },
        [props]
    );

    const clear = useCallback(
        (event, shouldTriggerClick = true) => {
            timeout.current && clearTimeout(timeout.current);
            shouldTriggerClick && !longPressTriggered && props.onClick(event);
            setLongPressTriggered(false);
            if (props.obj.shouldPreventDefault && target.current) {
                target.current.removeEventListener("touchend", preventDefault);
            }
        },
        [longPressTriggered, props]
    );

    return {
        onMouseDown: (e: any) => start(e),
        onTouchStart: (e: any) => start(e),
        onMouseUp: (e: any) => clear(e),
        onMouseLeave: (e: any) => clear(e, false),
        onTouchEnd: (e: any) => clear(e)
    };
};

const isTouchEvent = (event: any) => {
    return "touches" in event;
};

const preventDefault = (event: any) => {
    if (!isTouchEvent(event)) return;

    if (event.touches.length < 2 && event.preventDefault) {
        event.preventDefault();
    }
};

export default useLongPress;

Use of that common function

import useLongPress from "shared/components/longpress";

    const onLongPress = () => {
        console.log('longpress is triggered');
        // setlongPressCount(longPressCount + 1)
    };

    const onClick = () => {
        console.log('click is triggered')
        // setClickCount(clickCount + 1)
    }

    const defaultOptions = {
        shouldPreventDefault: true,
        delay: 500,
    };


<div {...longPressEvent}></div>
1

Thanks, @sudo bangbang for this great custom hook.

I had some problems, though:

When I was scrolling through a table with a mobile device (touch input), this hook accidentally triggered a click during the scrolling. Of course, this is not want we want.

Another problem was if I was scrolling very slowly, the hook accidentally triggered the long press.

I managed to circumvent this behavior with subtle changes:

// Set 'shouldPreventDefault' to false to listen also to 'onMouseUp', 
// would be canceled otherwise if 'shouldPreventDefault' would have been 'true'
const defaultOptions = { shouldPreventDefault: false, delay: 500 };
    return {
        onMouseDown: (e) => start(e),
        onTouchStart: (e) => start(e),
        onMouseUp: (e) => clear(e),
        onMouseLeave: (e) => clear(e, false),
        onTouchEnd: (e) => clear(e, false), // Do not trigger click here
        onTouchMove: (e) => clear(e, false), // Do not trigger click here
    };

Here is my implementation with the modifications

import { useCallback, useRef, useState } from "react";

// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// Custom hook to handle a long press event (e.g. on mobile for secondary action)
// https://stackoverflow.com/a/48057286/7220665
// Usage:
//      const onLongPress = () => {console.info('long press is triggered')};
//      const onClick = () => {console.info('click  is triggered')};
//      const defaultOptions = { shouldPreventDefault: false, delay: 500 };
//      const longPressEvent = useLongPress(onLongPress, onClick, defaultOptions);
//      return <button {...longPressEvent}>do long Press</button>
//
// If we are scrolling with the finger 'onTouchStart' and 'onTouchEnd' is triggered
// if we are clicking with the finger additionally to 'onTouchStart' and 'onTouchEnd' ->
// 'onMouseDown' 'onMouseUp' is triggered as well
// We do not want a click event if the user is just scrolling (e.g. in a list or table)
// That means 'onTouchEnd' should not trigger a click
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

// Hook
const useLongPress = (onLongPress, onClick, { shouldPreventDefault = true, delay = 300 } = {}) => {
    // console.info("useLongPress");

    const [longPressTriggered, setLongPressTriggered] = useState(false);
    const timeout = useRef();
    const target = useRef();

    //
    // Start the long press if 'onMouseDown' or 'onTouchStart'
    const start = useCallback(
        (event) => {
            console.info("useLongPress start");

            // Create listener
            if (shouldPreventDefault && event.target) {
                event.target.addEventListener("touchend", preventDefault, { passive: false });
                target.current = event.target;
            }

            // A long press event has been triggered
            timeout.current = setTimeout(() => {
                onLongPress(event);
                setLongPressTriggered(true);
            }, delay);
        },
        [onLongPress, delay, shouldPreventDefault]
    );

    //
    // Clear the long press if 'onMouseUp', 'onMouseLeave' or 'onTouchEnd'
    const clear = useCallback(
        (event, shouldTriggerClick = true) => {
            console.info("useLongPress clear event:", event);

            timeout.current && clearTimeout(timeout.current);
            shouldTriggerClick && !longPressTriggered && onClick(event);
            setLongPressTriggered(false);

            // Create listener
            if (shouldPreventDefault && target.current) {
                target.current.removeEventListener("touchend", preventDefault);
            }
        },
        [shouldPreventDefault, onClick, longPressTriggered]
    );

    //
    //
    return {
        onMouseDown: (e) => start(e),
        onTouchStart: (e) => start(e),
        onMouseUp: (e) => clear(e),
        onMouseLeave: (e) => clear(e, false),
        onTouchEnd: (e) => clear(e, false), // Do not trigger click here
        onTouchMove: (e) => clear(e, false), // Do not trigger click here
    };
};

//
// Check if it is a touch event - called by 'preventDefault'
const isTouchEvent = (event) => {
    console.info("useLongPress isTouchEvent");
    return "touches" in event;
};

//
//
const preventDefault = (event) => {
    console.info("useLongPress preventDefault");

    if (!isTouchEvent(event)) return;

    if (event.touches.length < 2 && event.preventDefault) {
        if (event.cancelable) event.preventDefault();
    }
};

export default useLongPress;

Now a click is NOT triggered onTouchUp (which will be called if we are scrolling through a list or table) but onMouseUp, which will be triggered additionally to onTouchUp if we are scrolling (although we are not really using a mouse)

Michael
  • 393
  • 2
  • 4
  • 20
  • I think `shouldPreventDefault` should be set to `false` by default (as you described at a start of your answer) but is set to `true` in the implementation, shouldn't it? Anyway, with that change, it seems to work great, thank you! – patryk-szwed Apr 14 '23 at 07:27
  • Pleasure to help. I am still surprised that this behavior is not avaliable via library but has to be implemented by oneself – Michael Apr 15 '23 at 08:06
0

Overview

The following removes the need for a hook and instead adds onLongPress handler to a button and also overrides the event.detail to accurately track double clicks, triple clicks, and beyond on both desktop and mobile.

It can be used like this:

<Button
  onClick={e => {
     // accurate on both desktop and mobile
     if (e.detail === 2) {
        console.log("double click")
     } else if (e.detail === 3) {
        console.log("triple click")
     }
  })
  onLongPress={(e, pressDuration) => {
     console.log("executes once or every 10ms depending on additional prop")
  })
>
  Click Me, Touch Me, Hold Me Baby
</Button>

The onClick will increment an internal counter every time the button is clicked within a debounced 400ms time limit, and then reset the counter back to 0 after more than 400ms have passed without a click.

The onLongPress will be executed every 10ms after the button has been held down for at least 5000ms. It returns the event that triggered it (either click or touch event) and the length of time in ms the button has been held down. Two additional props are available for a long press:

  1. longPressThreshold: number - the number of ms until the longPress is trigged.
  2. longPressOnce: boolean - determines if the longPress fn is only executed once or repeatedly after the threshold is reached

So to trigger the longPress only once after holding down for 3 seconds (instead of the default 5 seconds):

<Button
  onClick={e => {
     if (e.detail === 2) {
        console.log("double click")
     } else if (e.detail === 3) {
        console.log("triple click")
     }
  })
  longPressOnce
  longPressThreshold={3000}
  onLongPress={(e, pressDuration) => {
     console.log("executes once")
  })
>
  Longpress
</Button>

Implementation

The implementation involves creating a button component that wraps over a regular html button. A stopwatch hook is needed to time how long the button has been pressed and a debounce hook is needed to reset the click counter after 400ms.

useStopwatch

import React from "react";

export default function useStopwatch() {
  const [time, setTime] = React.useState(0);
  const [active, setActive] = React.useState(false);

  React.useEffect(() => {
    let interval: NodeJS.Timer | null = null;

    if (active) {
      interval = setInterval(() => {
        setTime((prevTime) => prevTime + 10);
      }, 10);
    } else {
      clearInterval(interval!);
    }

    return () => clearInterval(interval!);
  }, [active]);

  const start = () => setActive(true);
  const reset = () => {
    setActive(false);
    setTime(0);
  };

  return { time, start, reset };
}

useDebounce

import React from "react";

/**
 * Debounce a function
 * @param pulse
 * @param fn
 * @param delay
 */
export default function useDebounceFn<T = unknown>(
  pulse: T,
  fn: () => void,
  delay: number = 500
) {
  const callbackRef = React.useRef(fn);
  React.useLayoutEffect(() => {
    callbackRef.current = fn;
  });

  // reset the timer to call the fn everytime the pulse value changes
  React.useEffect(() => {
    const timerId = setTimeout(fn, delay);
    return () => clearTimeout(timerId);
  }, [pulse, delay]);
}

With these two hooks, we can now create the final implementation:

Button component

import React from "react";

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  /** only call longpress fn once */
  longPressOnce?: boolean;
  /** the number of ms needed to trigger a longpress */
  longPressThreshold?: number;
  onLongPress?: (
    e:
      | React.MouseEvent<HTMLButtonElement>
      | React.TouchEvent<HTMLButtonElement>,
    pressDuration: number
  ) => void;
}
export default function Button(props: Props) {
  // do not attach onClick or onLongPress to button directly,
  // instead we will decide when either is called
  const { children, onClick, onLongPress, ...rest } = props;

  // track click count on both browser and mobile using e.detail
  const [clickCount, setClickCount] = React.useState(0);

  // reset click counter to 0 after going 400ms without a click
  useDebounceFn(clickCount, () => setClickCount(0), 400);

  // long press stuff starts here

  // store the event that triggered the long press (click or touch event)
  const evt = React.useRef<any | null>(null);

  // store functions in a ref so they can update state without going stale
  const longPressRef = React.useRef<any>();
  const clickRef = React.useRef<any>();

  const stopwatch = useStopwatch();
  const [touched, setTouched] = React.useState(false);
  const [longPressedOnce, setLongPressedOnce] = React.useState(false);
  const pressDurationRef = React.useRef(0);

  pressDurationRef.current = stopwatch.time;
  const longPressThreshold = props.longPressThreshold ?? 500;

  // keep click and long press fns updated in refs
  React.useEffect(() => {
    longPressRef.current = onLongPress;
    clickRef.current = onClick;
  }, [onLongPress, onClick]);

  // onClick handling
  React.useEffect(() => {
    const pressDuration = pressDurationRef.current;
    // when the user starts holding down the button,
    // immediately begin the stopwatch
    if (touched) {
      stopwatch.start();
    } else {
      // otherwise if the user has just released the button and
      // it is under 500ms, then trigger the onClick and
      // increment click counter
      if (pressDuration && pressDuration < 500) {
        const updatedClickCount = clickCount + 1;
        setClickCount(updatedClickCount);
        evt.current.detail = updatedClickCount;
        clickRef.current?.(evt.current);
      }
      // finally reset the stopwatch since button is no longer held down
      stopwatch.reset();
    }
  }, [touched]);

  // long press handling
  React.useEffect(() => {
    if (!longPressRef.current) return;
    const pressDuration = pressDurationRef.current;

    // if the button has been held down longer than longPress threshold,
    // either execute once, or repeatedly everytime the pressDuration
    // changes, depending on the props provided by the user
    if (pressDuration > longPressThreshold) {
      if (props.longPressOnce) {
        // skip if long press has already been
        // executed once since being touched
        if (longPressedOnce || !touched) return;
        longPressRef.current(evt, pressDuration);
        setLongPressedOnce(true);
      } else {
        // otherwise keep calling long press every 10ms, passing the
        // event and how long the button has been held to the caller
        longPressRef.current(evt, pressDuration);
      }
    }
  }, [pressDurationRef.current, longPressThreshold, longPressedOnce, touched]);

  const isMobile = window.matchMedia("(max-width: 767px)").matches;
 
  const pressProps = isMobile
        ? {
            onTouchStart: (e) => {
              evt.current = e;
              setTouched(true);
              props.onTouchStart?.(e);
            },
            onTouchEnd: (e) => {
              setLongPressedOnce(false);
              setTouched(false);
              props.onTouchEnd?.(e);
            },
          }
        : {
            onMouseDown: (e) => {
              // globally store the click event
              evt.current = e;
              setTouched(true);
              props.onMouseDown?.(e);
            },
            onMouseUp: (e) => {
              setLongPressedOnce(false);
              setTouched(false);
              props.onMouseUp?.(e);
            },
            onMouseLeave: (e) => {
              setLongPressedOnce(false);
              setTouched(false);
              props.onMouseLeave?.(e);
            },
          }

  return (
    <button {...args} {...pressProps}>
       {children}
    </button>
  )
}

55 Cancri
  • 1,075
  • 11
  • 23