React useState fires multiple times

I have a like button, which once clicked increases the default value setLikedNumbers(likedNumbers + 1); by one. When the button is pressed again, it is decreased by one using setLikedNumbers(likedNumbers - 1); This works fine until the button is pressed multiple times per second, which creates some weird values. React strict mode tags are removed.

Video of the problem: https://vimeo.com/593482477 In the beginning I click the button slowly, then I proceed to do it multiple times per second and then axios catches up with the requsts.

Even if the button is incremented multiple times before axios returns an error code and return the current value – 1, shouldn’t that mean that the original value is retained as the number of increases is equal to the number of decreases?

The code from where I suspect the issue is(I stripped some unneeded lines):

The component

 <ToggleIcon
                    on={liked}
                    onIcon={
                      <FavoriteOutlinedIcon onClick={() => unlike(props.idData)} />
                    }
                    offIcon={
                      <FavoriteBorderIcon onClick={() => like(props.idData)} />
                    }
                  />

The javascript

 const like = async (id) => {
        await setLiked(true);
        await setLikedNumbers(likedNumbers + 1);
        axios
          .request({
            method: "POST",
            url: `http://localhost:5000/like`,
            headers: {
              jwt: localStorage.getItem("jwt"),
            },
            data: {
              place_id: id,
            },
          })
          .catch(async (err) => {
            await setLiked(false);
            await setLikedNumbers(likedNumbers - 1);
          });
      };
      const unlike = async (id) => {
        await setLiked(false);
        await setLikedNumbers(likedNumbers - 1);
        axios
          .request({
            method: "POST",
            url: `http://localhost:5000/unlike`,
            headers: {
              jwt: localStorage.getItem("jwt"),
            },
            data: {
              place_id: id,
            },
          })
          .catch(async (err) => {
            await setLiked(true);
            await setLikedNumbers(likedNumbers + 1);
      };
    

The whole code

import React from "react";
import "react-responsive-carousel/lib/styles/carousel.min.css"; // requires a loader
import Box from "@material-ui/core/Box";
import Card from "@material-ui/core/Card";
import CardActionArea from "@material-ui/core/CardActionArea";
import CardActions from "@material-ui/core/CardActions";
import CardContent from "@material-ui/core/CardContent";
import CardMedia from "@material-ui/core/CardMedia";
import Typography from "@material-ui/core/Typography";
import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder";
import BookmarkBorderIcon from "@material-ui/icons/BookmarkBorder";
import Dialog from "@material-ui/core/Dialog";
import DialogTitle from "@material-ui/core/DialogTitle";
import DialogContent from "@material-ui/core/DialogContent";
import DialogActions from "@material-ui/core/DialogActions";
import { Carousel } from "react-responsive-carousel";
import ReportOutlinedIcon from "@material-ui/icons/ReportOutlined";
import ShareOutlinedIcon from "@material-ui/icons/ShareOutlined";
import FavoriteOutlinedIcon from "@material-ui/icons/FavoriteOutlined";
import BookmarkOutlinedIcon from "@material-ui/icons/BookmarkOutlined";
import ToggleIcon from "material-ui-toggle-icon";

const axios = require("axios");

const CardElement = (props) => {
  const [open, setOpen] = React.useState(false);
  const [liked, setLiked] = React.useState(props.liked);
  const [saved, setSaved] = React.useState(props.saved);
  const [likedNumbers, setLikedNumbers] = React.useState(props.numbersLiked);
  const handleClickOpen = () => {
    setOpen(true);
  };
  const like = async (id) => {
    await setLiked(true);
    await setLikedNumbers(likedNumbers + 1);
    axios
      .request({
        method: "POST",
        url: `http://localhost:5000/like`,
        headers: {
          jwt: localStorage.getItem("jwt"),
        },
        data: {
          place_id: id,
        },
      })
      .catch(async (err) => {
        await setLiked(false);
        await setLikedNumbers(likedNumbers - 1);
      });
  };
  const unlike = async (id) => {
    await setLiked(false);
    await setLikedNumbers(likedNumbers - 1);
    axios
      .request({
        method: "POST",
        url: `http://localhost:5000/unlike`,
        headers: {
          jwt: localStorage.getItem("jwt"),
        },
        data: {
          place_id: id,
        },
      })
      .catch(async (err) => {
        await setLiked(true);
        await setLikedNumbers(likedNumbers + 1);
  };

  const save = (id) => {
    setSaved(true);
    axios
      .request({
        method: "POST",
        url: `http://localhost:5000/save`,
        headers: {
          jwt: localStorage.getItem("jwt"),
        },
        data: {
          place_id: id,
        },
      })
      .catch((err) => {
        setSaved(false);
       
      });
  };
  return (
    <div>
      <Card className="card">
        <CardActionArea
          onClick={() => {
            handleClickOpen();
          }}
        >
          {props.mainImg ? (
            <CardMedia
              className="mediaImgOverview"
              image={"http://localhost:5000/image/" + props.mainImg}
            />
          ) : (
            ""
          )}
          <CardContent>
            <Typography gutterBottom variant="h5" component="h2">
              {props.title}
            </Typography>
            <Typography variant="body2" color="textSecondary" component="p">
              {props.description.length > 45
                ? props.description.substring(0, 45) + "..."
                : props.description}
            </Typography>
          </CardContent>
        </CardActionArea>
        <CardActions className="ButtonHolder">
          <Box className="likesContainer">
            {props.likeButtonVisible ? (
              <ToggleIcon
                on={liked}
                onIcon={
                  <FavoriteOutlinedIcon onClick={() => unlike(props.idData)} />
                }
                offIcon={
                  <FavoriteBorderIcon onClick={() => like(props.idData)} />
                }
              />
            ) : (
              ""
            )}
            <Typography
              style={{ marginLeft: props.likeButtonVisible ? 0 : "0.2vmax" }}
            >
              {likedNumbers}
            </Typography>
          </Box>
          {props.likeButtonVisible ? (
            <ToggleIcon
              on={saved}
              onIcon={
                <BookmarkOutlinedIcon onClick={() => unsave(props.idData)} />
              }
              offIcon={
                <BookmarkBorderIcon onClick={() => save(props.idData)} />
              }
            />
          ) : (
            ""
          )}
        </CardActions>
      </Card>
      <Dialog
        maxWidth="md"
        onClose={handleClose}
        aria-labelledby="MoreInfo"
        open={open}
      >
        <DialogTitle id="MoreInfo" onClose={handleClose}>
          {props.title}
        </DialogTitle>
        <DialogContent dividers>
          {props.images[0].url ? (
            <Carousel infiniteLoop="true">
              {props.images.map((el) => {
                return (
                  <div key={Math.random()}>
                    <img alt="" src={"http://localhost:5000/image/" + el.url} />
                    <p className="legend">{el.caption}</p>
                  </div>
                );
              })}
            </Carousel>
          ) : (
            ""
          )}

          <Typography gutterBottom>
            <Typography>
              <b>Категория: </b>
              {category(props.category)}
              <b> Опасно: </b>
              {dangerous(props.dangerous)}
              <b> Цена: </b>
              {price(props.price)} <b> Достъпност: </b>
              {accessibility(props.accessibility)}
            </Typography>
            {props.description}
          </Typography>
        </DialogContent>
        <DialogActions className="btnCard">
          <Box className="likesContainer">
            {props.likeButtonVisible ? (
              <ToggleIcon
                on={liked}
                onIcon={
                  <FavoriteOutlinedIcon onClick={() => unlike(props.idData)} />
                }
                offIcon={
                  <FavoriteBorderIcon onClick={() => like(props.idData)} />
                }
              />
            ) : (
              ""
            )}
            <Typography
              style={{ marginLeft: props.likeButtonVisible ? 0 : "0.2vmax" }}
            >
              {likedNumbers == 0
                ? "Няма харесвания"
                : likedNumbers == 1
                ? "1 харесване"
                : likedNumbers + " харесвания"}
            </Typography>
          </Box>
          <Box>
            {props.likeButtonVisible ? (
              <ToggleIcon
                on={saved}
                onIcon={
                  <BookmarkOutlinedIcon onClick={() => unsave(props.idData)} />
                }
                offIcon={
                  <BookmarkBorderIcon onClick={() => save(props.idData)} />
                }
              />
            ) : (
              ""
            )}
            <ShareOutlinedIcon />
            {props.reportButtonVisible ? <ReportOutlinedIcon /> : ""}
          </Box>
        </DialogActions>
      </Dialog>
    </div>
  );
};
export default CardElement;

Update 1

After a few experiments I am fairly certain that the problem is the slow change of the button(animation) which causes triggering of the other function, e.g. like instead of unlike or vice versa. I am trying to find a way to solve this.

Update 2 Problem solved by creating a wrapper function that calls the right like/unlike function regardless of which button is present.

Answer

I’d recommend to look at the RxJS lib and limiting the number of requests to the api (it will improve the browser performance). Check out the short snippet below:

// Create a subject which will store the latest value
const buttonClicked = new Subject<{itemId: string, isLiked: boolean}>();

// Debounce for 200 sec.
// debounceTime - Emits a notification from the source Observable only after a particular time span has passed without another source emission. (ref: https://rxjs.dev/api/operators/debounceTime)
const buttonClickedDebounced = buttonClicked.pipe(debounceTime(200));

// If no click was triggered due the time, execute the call to api with latest params. 
buttonClickedDebounced.subscribe(({itemId: string, isLiked: boolean}) =>
     {
        axios.request({...})
        // After you get a response it's nice to update the number or likes as well, as soon the other User could like/unlike the same card.
     }
);

// Register the click event
function like(itemId, isLiked) {
 setLiked(isLiked)
 buttonClicked.next({itemId, isLiked})
}

With such approach you will sent only 1 request to api with the latest state that User decided to left (either liked or not) after multiple clicks. Hope this will help to improve your application!