javascriptreactjsjsxnextui

My React (not strict mode) function executes twice in swipe gesture


I have some kind of a swipe functionality on list items in my React application. The swipe functionality is working, but somehow the right swipe function gets executed twice. I am not using React StrictMode, so that isn't the problem.

Here is my list item component simplified:

<Card 
        isPressable={!finished} 
        onClick={() => {
            handleClick(false);
        }}
        onTouchStart={(e) => {
            setTouchEnd(null);
            setTouchStart(e.targetTouches[0].clientX);
        }}
        onTouchMove={(e) => {
            setTouchEnd(e.targetTouches[0].clientX);
        }}
        onTouchEnd={() => {
            if (!touchStart || !touchEnd) return;
            const distance = touchStart - touchEnd;
            const isLeftSwipe = distance > minSwipeDistance;
            const isRightSwipe = distance < -minSwipeDistance;
            if(isLeftSwipe && finished) {
                handleLeftSwipe();
            }
            else if(isRightSwipe && !finished) { 
                handleRightSwipe(); 
            }
        }} 
        onContextMenu={() => handleClick(true)}

minSwipeDistance is a const: const minSwipeDistance = 50;

The onTouchEnd also executes twice when I swipe right. The handleSwipeRight function itself doesn't need to be debugged, because I literally exchanged it for only a console log and it was still being executed twice.

For the rest I am not doing anything special in my useEffects.

A listitem should not be able to be swiped left when the item is not finished and vice versa for finished items with right swipe.

enter image description here enter image description here enter image description here

My whole list Item component: (swipedRight state is only for CSS purposes)

import { Avatar, Card, Modal, Row, Text } from "@nextui-org/react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { finishOrderRule, showToast, getOrderRule } from "../../utils/api.js";
import { useAuth } from "../../App.js";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faInfo } from "@fortawesome/free-solid-svg-icons";

export default function OrderRule(props){

    const [amountOfFields, setAmountOfFields] = useState(props.datafields?.length);
    const [finished, setFinished] = useState(props.data?.Finished);
    const [swipedLeft, setSwipedLeft] = useState(false);
    const [swipedRight, setSwipedRight] = useState(false);
    const [isOpen, setIsOpen] = useState(false);
    const [touchStart, setTouchStart] = useState(null);
    const [touchEnd, setTouchEnd] = useState(null);
    const [selected, setSelected] = useState(props.selected === props.data?.Oid);

    const order = props.order;
    const isAllRules = props.isAllRules;
    const rule = props.data;
    const datafields = props.datafields;
    const navigate = useNavigate();
    const { token } = useAuth();
    const minSwipeDistance = 50; 

    const noteField = {
        name: "Note", 
        type: "Regels", 
        label: "Notitie"
    }

    useEffect(() => {
        setSelected(props.selected === rule?.Oid);
    }, [props.selected]);

    useEffect(() => {
        setAmountOfFields(props.datafields.length);
    }, [props.datafields]);

    function getParts(fieldname, isOrderProperty){
        const parts = fieldname.split(".");
        if(parts.length === 1) return isOrderProperty ? order?.[fieldname] : rule?.[fieldname];
        else if (parts.length === 2) return isOrderProperty ? order?.[parts[0]]?.[parts[1]] : rule?.[parts[0]]?.[parts[1]];
    }

    function handleClick(long){
        if (long) navigate(`/orders/${order?.Oid}/${rule?.Oid}`, { state: rule, replace: false });
        else props.setSelectedRule(rule?.Oid);
    }

    function handleIconClick(e){
        e.stopPropagation();
        setIsOpen(true);
    }

    function handleRightSwipe(){
        console.log("right swipe");
        setSwipedRight(true);
        setFinished(true);
        finishOrderRule(rule?.Oid, true, token)
            .catch(error => {
                setFinished(false);
                if(error.statusCode === 401) {
                    showToast("Je moet opnieuw inloggen of je hebt geen rechten om deze actie uit te voeren.", error.statusCode);
                    navigate("/login");
                }
                else { 
                    showToast(error.message, error.statusCode)
                    console.log(error); 
                }
            })
            .finally(() => {
                getOrderRule(rule?.Oid, token)
                    .then((data) => {
                        props.modifyRule(data.value[0]);
                    })
                    .catch(error => {
                        setFinished(true);
                        if(error.statusCode === 401) {
                            showToast("Je moet opnieuw inloggen of je hebt geen rechten om deze actie uit te voeren.", error.statusCode);
                            navigate("/login");
                        }
                        else { 
                            showToast(error.message, error.statusCode)
                            console.log(error); 
                        }
                    })
                    .finally(() => {
                        setSwipedRight(false);
                        props.setSelectedRule(null);
                    });
            });
    }

    function handleLeftSwipe(){
        console.log("left swipe");
        setSwipedLeft(true);
        setFinished(false);
        finishOrderRule(rule?.Oid, false, token)
            .catch(error => {
                setFinished(true);
                if(error.statusCode === 401) {
                    showToast("Je moet opnieuw inloggen of je hebt geen rechten om deze actie uit te voeren.", error.statusCode);
                    navigate("/login");
                }
                else { 
                    showToast(error.message, error.statusCode)
                    console.log(error); 
                }
            })
            .finally(() => {
                setSwipedLeft(false);
                getOrderRule(rule?.Oid, token)
                    .then((data) => {
                        props.modifyRule(data.value[0]);
                    })
                    .catch(error => {
                        setFinished(true);
                        if(error.statusCode === 401) {
                            showToast("Je moet opnieuw inloggen of je hebt geen rechten om deze actie uit te voeren.", error.statusCode);
                            navigate("/login");
                        }
                        else { 
                            showToast(error.message, error.statusCode)
                            console.log(error); 
                        }
                    })
                    .finally(() => {});
            });
    }

    function isDate(fieldname){ return fieldname.toLowerCase().includes("date") }
    
    const ModalComponent = () => {
        return (
            <Modal css={{zIndex: 10, m: 10}} closeButton open={isOpen} onClose={() => { setIsOpen(false); }}>
                <Modal.Header css={{p: 0}}><Text color="primary" size={26}>{rule?.Product?.Name}</Text></Modal.Header>
                <Modal.Body css={{p: 20, pt: 0}}>
                    <Text weight="medium" size={15} css={{textAlign: "center"}}>{rule?.Product?.Description}</Text>     
                    <Text weight="medium" size={15} css={{textAlign: "center"}}>{rule?.Note}</Text>     
                </Modal.Body>   
            </Modal>
        );
    }

    const NoteField = ({ width }) => {
        return (
            <div style={{pointerEvents: "none", width: width, display: "flex", justifyContent: "center", alignContent: "center"}}>
                {(rule?.Note !== null && 
                    <Avatar onClick={(e) => handleIconClick(e)} color={"white"} size="sm" css={{display: "flex", pointerEvents: "auto", border: "2px solid black", alignContent: "center", justifyContent: "center", justifyItems: "center", alignItems: "center"}} bordered icon={<FontAwesomeIcon icon={faInfo} />} />) || (
                    <Avatar color={"white"} size="sm" css={{border: "2px solid black", opacity: "10%"}} bordered icon={<FontAwesomeIcon icon={faInfo} />} />)}
            </div>
        );
    }

    const Field = ({ field }) => {
        const obj = field.type === "Orders" ? getParts(field.name, true) : getParts(field.name, false);
        const wi = ((100 / amountOfFields).toString() + "%").toString();
        return field.name === "Note" ? <NoteField width={wi} /> : <Text weight="medium" size={15} css={{pointerEvents: "none", width: wi, textAlign: "center", lineHeight: "100%"}}>{isDate(field.name) ? new Date(obj).toLocaleDateString().toString() : typeof(obj) === "boolean" ? (obj === true ? "Ja" : "Nee") : obj === "" ? "-" : obj === null ? "-" : obj}</Text>;
    }
    
    return (
        <Card 
            isPressable={!finished} 
            onClick={() => { handleClick(false); }}
            onTouchStart={(e) => {
                setTouchEnd(null);
                setTouchStart(e.targetTouches[0].clientX);
            }}
            onTouchMove={(e) => {
                setTouchEnd(e.targetTouches[0].clientX);
            }}
            onTouchEnd={() => {
                if (!touchStart || !touchEnd) return;
                const distance = touchStart - touchEnd;
                const isLeftSwipe = distance > minSwipeDistance;
                const isRightSwipe = distance < -minSwipeDistance;
                if(isLeftSwipe && finished) {
                    handleLeftSwipe();
                }
                if(isRightSwipe && !finished) { 
                    handleRightSwipe();
                }
            }} 
            onContextMenu={() => handleClick(true)}
            css={{p: "0px 10px 0px 10px", w: 'auto', m: "6.5px", h: "55px", justifyContent: "center"}}
            className={
                finished && swipedRight ? "listItem swipedRight finished" : 
                finished && swipedLeft ? "listItem swipedLeft finished" : 
                selected && swipedRight ? "listItem selected swipedRight" : 
                selected ? "listItem selected" : 
                swipedLeft ? "listItem swipedLeft" : 
                swipedRight ? "listItem swipedRight" : 
                finished ? "listItem finished" : 
                "listItem"
            }
        >
            <ModalComponent key="modal" />
            {isAllRules && <div style={selected ? {position: 'absolute', left: 2, top: -4.5} : {position: 'absolute', left: 5, top: -2.5}}><Text size={13} color="primary">{`Order ${order?.Number}`}</Text></div>}
            <Row justify="space-evenly" css={{alignItems: "center"}}>
                {datafields.map((field) => (
                    <Field key={field.name} field={field} />
                ))}
                <Field key="Notitie" field={noteField} />
            </Row>
        </Card>
    );
}

Solution

  • I switched UI-libraries from Next.UI to Mantine.dev and somehow that fixed this issue.

    I think it had something to do with the rendering of the Card component.