You Don't Need NPM Libraries To Handle Mobile Events

Written by msokola | Published 2024/03/03
Tech Story Tags: mobile-development | react | reactjs | react-18 | mobile-apps | npm-libraries | mobile-app-development | react-components

TLDREveryone wants to be able to access the web from their mobile phones these days. So it's important to consider the accessibility of your web app on Android and iPhones. You can find the source code in the Conclusion section.via the TL;DR App

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.

Quick intro

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.

Custom React Component - MobileSwiper

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:

  1. I wrapped this helper into the useCallback hook to improve the performance (caching).
  2. The 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.
  3. e.preventDefault() disables the default scrolling event.
  4. The last two lines store 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>
} 

Let’s use it

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:

Conclusion

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.



Written by msokola | Grew my career from $15,000 to $100,000+ annual salary. Now sharing everything I learned along the way.
Published by HackerNoon on 2024/03/03