Everybody wants to access web apps from their mobile phone and that’s why developers must create their React apps in a mobile-first fashion. There are multiple problems when comes to building apps for various devices such as many display resolutions, screen orientation, and touch events. Today, I’ll show you how to handle touch events in React like a pro without any NPM libraries. Obviously, libraries can make your lives easier but did you know it only takes 50 lines of code to create your own component to handle mobile swipe events? I will teach you how to do so.
As always, my articles are based on the problems I was solving in real life. Recently I decided to migrate my open-source version of the 2048 Game to Next.js and React 18 and this inspired me to rethink some parts of the initial design. To eliminate confusion, I will only focus on handling touch events in React and show you the best practices when comes to creating React apps that support mobile devices.
If you are intrigued by how to build the 2048 game from scratch, you should consider enrolling to my React course on Udemy. Link below. I will walk you through the entire process of creating the 2048 Game with animations. In the end, learning should bring us joy and fulfillment and nothing is more satisfying than making a complete game from scratch.
In early 2020, I released the very first version of my 2048 game. I wrote a short article describing my decision process and why I designed my code in a different way than other open-source versions. Immediately my article went viral on Reddit and FreeCodeCamp since it completely changed the perspective on this problem. My implementation was different than others because it supported mobile devices and had awesome animations. Other open source implementations of 2048 in React were janky and incomplete.
Despite my efforts, the initial version wasn’t perfect and that’s why I decided to refresh it. I updated it to React 18, changed the build process by migrating it to Next.js, and rewrote styles from LESS to classic CSS. Still, I wasn’t happy about the fact I used a lot of NPM libraries - especially libraries responsible for dealing with mobile touch events.
I used a library called hammerjs
and it was perfect to support multi-gesture touch events in JavaScript. Unfortunately, hammerjs
doesn’t support React and it forced me to create my own React wrapper for this library. But I found yet another problem - the NPM library hadn’t been updated for the last 8 years and it puzzled me. hammerjs
is downloaded more than 1.4 million times weekly but nobody is maintaining it.
Technically, my project wasn’t critical so I still could take advantage of this library. I always prioritize high quality of code but usage of unmaintained library cannot be considered as good practice. That’s why I created my component from scratch.
I decided to call this component MobileSwiper
and declared two props as its arguments – children
and onSwipe
:
export default function MobileSwiper({ children, onSwipe }) {
return null
}
Let me briefly explain them:
children
allow me to inject any HTML elements inside this component.onSwipe
is a callback that will be triggered each time user swipes within a given area. In my case, I want to enable mobile swipes on the game board only. All other parts of app should be scrollable.
To detect the swipeable area I created a reference to the DOM element. Basically, only this element will allow custom touch events.
I did it by using the useRef
hook, and using it to reference a div
element:
import { useRef } from "react"
export default function MobileSwiper({ children, onSwipe }) {
const wrapperRef = useRef(null)
return <div ref={wrapperRef}>{children}</div>
}
Once I declared the area I was ready to focus on handling swipes. The easiest way to detect them is by comparing the starting and ending positions of the user's finger. This means I needed to store the initial position of the user's finger in state ( x
and y
coordinates) and compare it with the last known finger position (before the user lifts the finger off their screen):
import { useState, useRef } from "react"
export default function MobileSwiper({ children, onSwipe }) {
const wrapperRef = useRef(null)
const [startX, setStartX] = useState(0)
const [startY, setStartY] = useState(0)
return <div ref={wrapperRef}>{children}</div>
}
Then I was ready to implement finger tracking, I created two callbacks using standard JavaScript touch events:
handleTouchStart
used to set the starting position of the user's finger.handleTouchEnd
handling the final position of the user's finger and calculating the shift (delta) from the starting point.
Let's look into the handleTouchStart
event handler first:
import { useCallback, useState, useRef } from "react"
export default function MobileSwiper({ children, onSwipe }) {
const wrapperRef = useRef(null)
const [startX, setStartX] = useState(0)
const [startY, setStartY] = useState(0)
const handleTouchStart = useCallback((e) => {
if (!wrapperRef.current.contains(e.target)) {
return
}
e.preventDefault()
setStartX(e.touches[0].clientX)
setStartY(e.touches[0].clientY)
}, [])
return <div ref={wrapperRef}>{children}</div>
}
Let me explain it:
useCallback
hook to improve the performance (caching).if
statement checks if user is swiping within a declared area. If they do it outside of it, the event will not be triggered and user will simply scroll the application.e.preventDefault()
disables the default scrolling event.x
and y
coordinates in state.
Now let's focus the handleTouchEnd
event handler:
import { useCallback, useState, useRef } from "react"
export default function MobileSwiper({ children, onSwipe }) {
const wrapperRef = useRef(null)
const [startX, setStartX] = useState(0)
const [startY, setStartY] = useState(0)
const handleTouchStart = useCallback((e) => {
if (!wrapperRef.current.contains(e.target)) {
return
}
e.preventDefault()
setStartX(e.touches[0].clientX)
setStartY(e.touches[0].clientY)
}, [])
const handleTouchEnd = useCallback((e) => {
if (!wrapperRef.current.contains(e.target)) {
return
}
e.preventDefault()
const endX = e.changedTouches[0].clientX
const endY = e.changedTouches[0].clientY
const deltaX = endX - startX
const deltaY = endY - startY
onSwipe({ deltaX, deltaY })
}, [startX, startY, onSwipe])
return <div ref={wrapperRef}>{children}</div>
}
As you can see, I’m taking the final x
and y
coordinates of user's finger and deduct the starting x
and y
coordinates from the final ones. Thanks to that I am able to calculate horizontal and vertical shift of the finger (aka deltas).
Then I passed calculated deltas onto the onSwipe
callback which will be coming from other components to promote reusability. PS. If you remember, I declared the onSwipe
callback in the component's props.
In the end, I needed to take advantage of created event callbacks and hook them into the event listener:
import { useCallback, useEffect, useState, useRef } from "react"
export default function MobileSwiper({ children, onSwipe }) {
const wrapperRef = useRef(null)
const [startX, setStartX] = useState(0)
const [startY, setStartY] = useState(0)
const handleTouchStart = useCallback((e) => {
if (!wrapperRef.current.contains(e.target)) {
return
}
e.preventDefault()
setStartX(e.touches[0].clientX)
setStartY(e.touches[0].clientY)
}, [])
const handleTouchEnd = useCallback(
(e) => {
if (!wrapperRef.current.contains(e.target)) {
return
}
e.preventDefault()
const endX = e.changedTouches[0].clientX
const endY = e.changedTouches[0].clientY
const deltaX = endX - startX
const deltaY = endY - startY
onSwipe({ deltaX, deltaY })
}, [startX, startY, onSwipe])
useEffect(() => {
window.addEventListener("touchstart", handleTouchStart)
window.addEventListener("touchend", handleTouchEnd)
return () => {
window.removeEventListener("touchstart", handleTouchStart)
window.removeEventListener("touchend", handleTouchEnd)
}
}, [handleTouchStart, handleTouchEnd])
return <div ref={wrapperRef}>{children}</div>
}
The MobileSwiper component is ready now and I can use it with my other components. As I mentioned at the beginning, I created this component to handle mobile swipes on the game board of my 2048 Game, and that’s where this example code comes from.
If you want to use it in your project feel free to copy & paste the handleSwipe
function and hook it as I did it on the game board:
import { useCallback, useContext, useEffect, useRef } from "react"
import { Tile as TileModel } from "@/models/tile"
import styles from "@/styles/board.module.css"
import Tile from "./tile"
import { GameContext } from "@/context/game-context"
import MobileSwiper from "./mobile-swiper"
export default function Board() {
const { getTiles, moveTiles, startGame } = useContext(GameContext);
// ... removed irrelevant parts ...
const handleSwipe = useCallback(({ deltaX, deltaY }) => {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > 0) {
moveTiles("move_right")
} else {
moveTiles("move_left")
}
} else {
if (deltaY > 0) {
moveTiles("move_down")
} else {
moveTiles("move_up")
}
}
}, [moveTiles])
// ... removed irrelevant parts ...
return (
<MobileSwiper onSwipe={handleSwipe}>
<div className={styles.board}>
<div className={styles.tiles}>{renderTiles()}</div>
<div className={styles.grid}>{renderGrid()}</div>
</div>
</MobileSwiper>
)
}
Let's focus on the handleSwipe
handler first. As you can see, I‘m comparing if deltaX
is greater than deltaY
to estimate if the user swiped horizontally (left/right) or vertically (top/bottom).
If it was a horizontal swipe, then:
negative deltaX
means they swiped to the left.
positive deltaX
means they swiped to the right.
If it was a vertical swipe, then:
negative deltaY
means they swiped up.
positive deltaY
means they swiped down.
Now, let's move on to the return
statement of the Board component. As you can see, here’s where my custom MobileSwiper component comes into play. I am passing the handleSwipe
helper to the onSwipe
property, and wrapping the HTML code of the game board to enable swiping on it.
It works but unfortunately, the result isn't perfect - scrolling events are mixed up with swipes:
This is happening because modern browsers use passive event listeners to improve the scrolling experience on mobile devices. it means that the preventDefault
I added that event handlers never take effect.
To fix scrolling behavior, I disabled passive listeners on the MobileSwiper
component by setting flag passive
to false
:
import { useCallback, useEffect, useState, useRef } from "react"
export default function MobileSwiper({ children, onSwipe }) {
// ... removed to improve visibility ...
useEffect(() => {
window.addEventListener("touchstart", handleTouchStart, { passive: false })
window.addEventListener("touchend", handleTouchEnd, { passive: false })
// ... removed to improve visibility ...
}, [handleTouchStart, handleTouchEnd])
// ... removed to improve visibility ...
}
Now the scrolling behavior is gone and my 2048 Game works like a charm:
I am not against using libraries because they can speed up your work but sometimes you can build your custom solution in 50 lines of code just like I showed you today. That’s why i believe that you don’t need any library to handle mobile events in React.
Wanna learn more tricks like this one?
If you wish to learn more React tricks like this one, or simply you want to say ‘thank you’, consider joining my course on Udemy where I will teach you how to create the 2048 game in React. You will not only learn clever tricks like that one but also find solutions to the most common mistakes that React developers make.
🧑🎓 Join React 18 course on Udemy (special discount for HackerNoon readers)
Believe it or not, this is the most up-to-date React 18 and Next.js course on Udemy. I released in January 2024 and I’ll keep updating it.