How to Detect Long Press Gestures in JavaScript Events in React
Interactions on the web are traditionally all about clicks, but then we had swipes, other gestures, and another option on click-like events: long press. How can we implement a long press interaction in our React apps without requiring native mobile tools?
What is a long press?
When dealing with a variety of devices, a click can have different meanings, but it boils down to a quick interaction with an item, which originally meant clicking your mouse (hence “click”) but can now sometimes refer to a user touching an item on a touch phone.
A long press on the other hand is pretty much the same thing, only that quick interaction takes a little bit longer, whether thats holding down your mouse button for a short period of time rather than immediately letting go or doing the same when interacting on a phone, which is where the term “long press” came from in the first place.
How does this work in React?
Using tools like React, we can use a single “click” listener to be able to trigger functionality between devices rather than custom listeners for each, but this gets trickier when thinking about how you’re going to tell if someone is trying to long press during an interaction.
This is where we need to bring in more specific event listeners, specifically listening for mouse events and touch events, determining how long the person is interacting for, and triggering the functionality depending on that time.
What are we going to build?
To learn how this works, we’re going to upgrade an existing button I created in a demo application to not only accept click events, but to accept long press events.
We’ll start off from a Next.js demo starter I created, where Next.js is absolutely not required for this, but it makes it a little easier for me to create some boilerplate code that you can follow with at home.
Step 0: Creating a new Next.js app from a demo starter
We’re going to start off with a new Next.js app using a starter that includes some simple UI that we’ll use.
Inside of your terminal, run:
yarn create next-app my-long-press -e https://github.com/colbyfayock/demo-long-press-starter
# or
npx create-next-app my-long-press -e https://github.com/colbyfayock/demo-long-press-starter
Note: feel free to use a different value than
my-welcome-banner
as your project name!
Once installation has finished, you can navigate to that directory and start up your development server:
cd my-long-press
yarn dev
# or
npm run dev
And once loaded, you should now be able to open up your new app at http://localhost:3000!
If you take a quick peak at what’s inside, you’ll notice I have set up some buttons, some actions that happen when you click those buttons, and the inclusion of different images depending on different action states.
As we work through, we’ll learn how to update this action state based on a long press, so that we can show a different image depending on what we do!
Step 1: Triggering functionality on mouse and touch events
Getting started, we want to be able to listen to the different events that are occurring on our button including whenever someone “touches” the button or when someone uses their mouse and pushes the button “down”.
We have to do this because our click event alone won’t let us determine the start of the interaction, as it will only fire after the interaction has occurred (such as after you lift your finger from the mouse or screen).
To start, we’re going to add some new event listeners to our primary “Click Me” button.
On our first button inside of pages/index.js
let’s update our button to:
<Button
onClick={handleOnClick}
onMouseDown={handleOnMouseDown}
onMouseUp={handleOnMouseUp}
onTouchStart={handleOnTouchStart}
onTouchEnd={handleOnTouchEnd}
>
Click or Press Me
</Button>
We’re adding events for both listening to when a mouse button goes down and goes up, as well as the same happening when someone touches the button on a device.
Next we need to define all of those different functions we just used.
Underneath our existing handleOnClick
function add:
function handleOnMouseDown() {
console.log('handleOnMouseDown');
}
function handleOnMouseUp() {
console.log('handleOnMouseUp');
}
function handleOnTouchStart() {
console.log('handleOnTouchStart');
}
function handleOnTouchEnd() {
console.log('handleOnTouchEnd');
}
We’re defining all of our functions and logging in our console what’s happening when the function is triggered.
Note: you can even add a log inside of
handleOnClick
if you want to follow along with how it differs!
If we head to our app now and start to interact with our primary button, we’ll be able to see right in the console all the events that are triggered.
The interesting thing we’ll notice is that we’re only triggering our mouse events, as we’re not interacting with a touch screen, and that our click event fires AFTER our mouse events.
Now we can simulate how this might look by using a real device simulator or inside of your browser, such as Chrome, you can open up the in-browser simulator.
Again, another interesting thing, is you’ll notice that we see both our touch events AND our mouse events, however, if you try to long press the button, you’ll notice we only see our touch start.
This is part of the mechanics we’ll use for determining if someone is long pressing or not!
Step 2: Determining if an interaction is a long press by setting up a timer
Currently we have no way to determine if someone is clicking or long pressing during their interaction, but the one thing that is clear between those interaction is the amount of time spent between them.
What I mean, is when you simply “click”, you’re very quickly interacting with something, whereas a long press, you’re intentionally holding down that interaction for a longer period of time.
So we can use time to our advantage where we basically need to determine how long someone is interacting with our element, but we don’t even need to go that far! All we need to know for our use case if the amount of time was MORE than what we determine a “long press” would be.
With that in mind, we can use the native setTimeout JavaScript function and do just that.
To start, we’re going to create a new function where we’re going to create this new timeout.
Somewhere next to our current interaction functions add:
function startPressTimer() {
setTimeout(() => {
setAction('longpress');
}, 500);
}
Here we’re creating our timeout that will last 500 milliseconds (half of 1 second) and once that timeout finishes, it will set our action state to longpress
.
Now we want to use our startPressTimer
function, so both inside of handleOnMouseDown
and handleOnTouchStart
which are both the initiating events, add:
function handleOnMouseDown() {
console.log('handleOnMouseDown');
startPressTimer();
}
function handleOnTouchStart() {
console.log('handleOnTouchStart');
startPressTimer();
}
If we head to our browser and test this out though, we’ll notice some mixed results.
First off if we simply click our button, we’ll notice that our action still triggers a long press. If we try to long press, we’ll notice it works, but then it switches to a click.
Like we kind of talked about earlier, our click handler is still going to fire, updating our action state to a click and on top of that, we never told our code to stop listening for a long press if we already know it’s not one.
So let’s work through this step by step:
- Cancel our long press timeout if we know it’s not a long press
- Prevent our click handler from firing if we know it’s a long press
To be able to cancel our timeout, we need to store a reference to it and to do that, we’re going to use React’s useRef hook.
At the top of the file we need to update our React import:
import { useState, useRef } from 'react';
Then we need to define our ref:
const timerRef = useRef();
And then when we invoke our setTimeout
function, we need to set the reference to our ref. Update startPressTimer
to:
timerRef.current = setTimeout(() => {
setAction('longpress');
}, 500);
We’re using the current
property of our ref, as that’s where we’re able to store the current value of our ref.
So now, we want to clear this timeout any time our interaction finishes, meaning, inside of our onTouchStart
and onMouseUp
functions, we’ll clear this timeout.
Update handleOnMouseUp
and handleOnTouchEnd
to:
function handleOnMouseUp() {
console.log('handleOnMouseUp');
clearTimeout(timerRef.current);
}
function handleOnTouchEnd() {
console.log('handleOnTouchEnd');
clearTimeout(timerRef.current);
}
If we head back to the browser, we’ll notice that our click is now working, but our long press still reverts to a click.
We’ve made progress, but now we need to fix our second issue to prevent our click handler from firing if we know it’s a long press.
To do this, we can store a separate reference that specifies whether or not we know it’s a long press. If we know it’s a long press, we simply won’t fire what’s inside of the onClick
handler.
Under our timerRef
add:
const isLongPress = useRef();
Next, if we know it’s a long press, we want to set it as such, so inside of our existing timeout, right next to where we update our action to longpress
, let’s update our ref:
function startPressTimer() {
timerRef.current = setTimeout(() => {
isLongPress.current = true;
setAction('longpress');
}, 500)
}
Note: we use a ref here instead of looking at our
action
state because the state reference won’t be available by the time our click event fires.
Then inside of our handleOnClick
function we can simply say that if we see that it’s a long press, don’t run!
function handleOnClick(e) {
console.log('handleOnClick');
if ( isLongPress.current ) {
console.log('Is long press - not continuing.');
return;
}
setAction('click')
}
But now if we test this out in the browser…
We can see that everything’s working just as we expect it to!
Now as one last issue, you might notice that once you long press, any time you click, it still thinks it’s a long press.
The issue here is we’re storing our reference that it’s a long press, but never reseting it.
To fix this, we can reset our reference any time the starting events occur.
Let’s make a final update to our startPressTimer
to:
function startPressTimer() {
isLongPress.current = false;
timerRef.current = setTimeout(() => {
isLongPress.current = true;
setAction('longpress');
}, 500)
}
Where we’re setting our reference to false before triggering out timeout.
But now, we can see now matter how many times we long press or click, it will update correctly!
Step 3: Wrapping long press detection in a custom React hook
Now if you’re satisfied with the implementation, you can bail here, but typically you’d want to re-use this type of interaction between different buttons, where in our current implementation, you would need to set up everything new for each one.
Instead we can create a custom hook in React where we can use a new instance of the hook any time we want to detect a long press on an interactive element!
This will go a little quick, so bear with me, as we’ll do a big cut and paste of our existing code.
To start off, we want to create a new location for our custom hook.
I personally like to include my hooks in a hooks
directory where if you’re following along, I already have @hooks
mapped to this directory for easy import.
Create a directory hooks
inside of the src
directory and then create a file called use-long-press.js
.
Inside src/hooks/use-long-press.js
add:
export default function useLongPress() {
return {}
}
At this point, all we’re doing is create a new function, where because we’re creating it as a hook, we’re going to have access to React internals like useState and useRef which we’re using.
Next, let’s copy ALL of the functions, state, and ref code that we just created in the previous steps over to this new hook, where, your hook should now look like this:
export default function useLongPress() {
const [action, setAction] = useState();
const timerRef = useRef();
const isLongPress = useRef();
function startPressTimer() {
isLongPress.current = false;
timerRef.current = setTimeout(() => {
isLongPress.current = true;
setAction('longpress');
}, 500)
}
function handleOnClick(e) {
console.log('handleOnClick');
if ( isLongPress.current ) {
console.log('Is long press - not continuing.');
return;
}
setAction('click')
}
function handleOnMouseDown() {
console.log('handleOnMouseDown');
startPressTimer();
}
function handleOnMouseUp() {
console.log('handleOnMouseUp');
clearTimeout(timerRef.current);
}
function handleOnTouchStart() {
console.log('handleOnTouchStart');
startPressTimer();
}
function handleOnTouchEnd() {
if ( action === 'longpress' ) return;
console.log('handleOnTouchEnd');
clearTimeout(timerRef.current);
}
return {}
}
Also, don’t forget to import our useState and useRef hooks at the top:
import { useState, useRef } from 'react';
As a last step for our custom hook, we need to return our necessarily functions and data so that we can actually use our hook.
Update the return statement to:
return {
action,
handlers: {
onClick: handleOnClick,
onMouseDown: handleOnMouseDown,
onMouseUp: handleOnMouseUp,
onTouchStart: handleOnTouchStart,
onTouchEnd: handleOnTouchEnd
}
}
Here we’re returning our action
variable which is our current action state, along with a property called handlers
which includes reference to all of our functions.
If you notice, I set each handler property to be the name that we would add to our Button, where as we’ll see in the implementation in a second, we can use the spread operator to add all of these functions to our element at once time ({...handlers}
).
And just like that, our hook is ready to use!
Back inside src/pages/index.js
we want to first make sure we remove all of those code we copied into our new hook, so there should literally be nothing before the return
statement inside of our page component.
But now, we can first import our hook at the top of src/pages/index.js
:
import useLongPress from '@hooks/use-long-press';
Then set up an instance of our hook:
const { action, handlers } = useLongPress();
And finally update our Button to use our new hook instance:
<Button {...handlers}>
Click or Press Me
</Button>
But now if we look in our browser, we should still see our click and press actions working exactly as they did before!
Now bonus, we can simply set up another instance, so that we use it on another button.
The following will break our Reset button, but we can try just to see how it works by first creating a new hook instance:
const { action: otherAction, handlers: otherHandlers } = useLongPress();
Then updating our Reset button:
<Button data-color="gray" {...otherHandlers}>
Reset
</Button>
And now if we head back to our browser and start to click and long press on both of the buttons…
We can see that using the Reset button maintains it’s own state and doesn’t impact our original button!
What else can we do?
Allow custom functions while maintaining functionality
Part of our goal here is to allow someone to run custom functionality, where once we moved our functions into the hook, we currently can’t do that without impacting other instances.
We can allow our hook to pass in custom functions, where if inside of the hook we see that function passed in as a prop, we can fire it along with our default functionality.
Add visual indicators
When allowing a click vs a long press, it can be helpful to indicate those actions to your visitors. Maybe when you hold it down it highlights a bit differently or plays a little animation that shows the button “filling up” or something along those lines!