DRE
DRE

Reputation: 323

React iterating over list and applying animation with delay between each iteration

I am having troubles fixing a bug basically I need to iterate over a list of buttons and apply an animation and on the next iteration I remove the animation from the previous element, however, when running the code the animation is started twice at the beginning and one element remains stuck with the animation applied.

The following is the code of the component:

import type { NextPage } from 'next'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { IoCheckmark, IoClose, IoHome, IoRefresh } from 'react-icons/io5'
import Page from '../components/page/Page'
import styles from '../styles/Play.module.css'
import { distance } from '../utils/distance'
import { randomInt, randomFloat } from '../utils/random'

function ShowSequence(props: any) {
    const [index, setIndex] = useState(0);
    const [sequence, setSequence] = useState(props.sequence);
    const [timer, setTimer] = useState<any>();

    useEffect(() => {
        console.log(index)
        if (index > 0) document.getElementById(sequence[index - 1])?.classList.toggle(styles.animate);
        if (index < sequence.length) document.getElementById(sequence[index])?.classList.toggle(styles.animate);
        else return clearInterval(timer);

        setTimer(setTimeout(() => setIndex(index + 1), 3000));
    }, [index]);

    return <div className={styles.button}>
        {
            props.map ? props.map.map((button: any) => {
                return <button key={button.buttonId} className={styles.button} id={button.buttonId} style={{ top: button.y + "px", left: button.x + "px", backgroundColor: button.color }}></button>
            }) : null
        }
    </div>;
}

function DoTask(props: any) {
    return <div>

    </div>;
}

function ChooseSequence(props: any) {
    const [sequence, setSequence] = useState(props.sequence);
    const [index, setIndex] = useState(0);
    const [timer, setTimer] = useState<any>();
    const [buttonMap, setButtonMap] = useState<any>({});

    console.log(sequence);

    return <div className={styles.button}>
        {
            props.map ? props.map.map((button: any) => {
                return <button key={button.buttonId} className={styles.button} id={button.buttonId} style={{ top: button.y + "px", left: button.x + "px", backgroundColor: button.color }} onClick={(e) => {
                    let correctSequence = sequence[index] === button.buttonId;
                    e.currentTarget.classList.toggle(correctSequence ? styles.correctButton : styles.wrongButton);
                    buttonMap[button.buttonId] = correctSequence ? <IoCheckmark size={20} color={"white"}></IoCheckmark> : <IoClose size={20} color={"white"}></IoClose>;
                    setButtonMap(buttonMap);
                    setIndex(index + 1);
                }}>
                    { (buttonMap[button.buttonId]) ? buttonMap[button.buttonId] : button.buttonId }
                </button>
            }) : null
        }
    </div>;
}

function Error(props: any) {
    return <div className={styles.errorMenu}>
        <h1>You lost!</h1>
        <p>You reached level: {props.level}</p>
        <div className={styles.container}>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
        </div>
        <div className={styles.row}>
            <button className={styles.retryButton} onClick={() => window.location.href = "/play"}><IoRefresh></IoRefresh></button>
            <button className={styles.closeButton} onClick={() => window.location.href = "/"}><IoHome></IoHome></button>
        </div>
    </div>;
}

enum State {
    SHOWSEQUENCE,
    DOTASK,
    CHOOSESEQUENCE,
    ERROR
}

const Play: NextPage = () => {
    let [state, setState] = useState<State>(State.SHOWSEQUENCE);
    let [sequence, setSequence] = useState<number[]>([randomInt(1, 20), randomInt(1, 20), randomInt(1, 20), randomInt(1, 20)]);
    let [map, setMap] = useState<any[]>();
    let [level, setLevel] = useState(1);

    let component;

    useEffect(() => {
        if (state === State.SHOWSEQUENCE) {
            let newSequenceId = randomInt(1, 20);
            setSequence((prevSequence: number[]) => [...prevSequence, newSequenceId])
        }
    }, [state]);

    useEffect(() => {
        let buttonIds = Array.from({ length: 20 }, (v, k) => k + 1);
        const { innerWidth, innerHeight } = window;

        let colors: string[] = ["#c0392b", "#e67e22", "#27ae60", "#8e44ad", "#2c3e50"];

        let buttonMap: any[] = [];

        let rows = buttonIds.length / 10;
        let columns = rows > 0 ? buttonIds.length / rows : buttonIds.length;

        for (let row = 0; row < rows; row++) {
            for (let col = 0; col < columns; col++) {
                let color = colors[Math.floor(randomFloat() * colors.length)];
                let x = innerWidth / columns * col + 100;
                let y = innerHeight / rows * row + 100;
                let offsetX = (randomFloat() < .5) ? -1 : 1 * randomFloat() * ((innerWidth / columns) - 100);
                let offsetY = (randomFloat() < .5) ? -1 : 1 * randomFloat() * ((innerHeight / rows) - 100);
                if (x + offsetX + 100 > innerWidth) offsetX -= ((x + offsetX) - innerWidth) + 100;
                if (y + offsetY + 100 > innerHeight) offsetY -= ((y + offsetY) - innerHeight) + 100;
                buttonMap.push({ buttonId: buttonIds[row * columns + col], x: x + offsetX, y: y + offsetY, color })
            }
        }

        setMap(buttonMap);
    }, [])

    switch (state) {
        case State.SHOWSEQUENCE:
            component = <ShowSequence map={map} sequence={sequence} changeState={() => setState(State.DOTASK)}></ShowSequence>;
            break;
        case State.DOTASK:
            component = <DoTask changeState={() => setState(State.CHOOSESEQUENCE)} onError={() => setState(State.ERROR)}></DoTask>
            break;
        case State.CHOOSESEQUENCE:
            component = <ChooseSequence map={map} sequence={sequence} changeState={() => setState(State.SHOWSEQUENCE)} onError={() => setState(State.ERROR)}></ChooseSequence>
            break;
    }

    return (
        <Page color="blue">
            { state === State.ERROR ? <Error level={level}></Error> : null }
            {component}
        </Page>
    )
}

export default Play

Here is a codesandbox.

Upvotes: 1

Views: 662

Answers (2)

Drew Reese
Drew Reese

Reputation: 203099

  1. Instead of querying the DOM, an anti-pattern in React, you should add the appropriate classname when mapping the data. This avoids the DOM mutations and handles the conditional logic for adding and removing the "animate" class.
  2. Use a functional state update to increment the index.
  3. I suggest using a React ref to hold a reference to the interval timer so it's not triggering additional rerenders
  4. Return a cleanup function to clear any running timers when necessary, i.e. when the component unmounts.

Code:

function ShowSequence(props) {
  const [index, setIndex] = useState(0);
  const timerRef = useRef();

  useEffect(() => {
    console.log(index);
    timerRef.current = setTimeout(() => setIndex((index) => index + 1), 3000);

    return () => {
      clearTimeout(timerRef.current);
    };
  }, [index]);

  return (
    <div className={styles.button}>
      {props.map?.map((button, i) => {
        return (
          <button
            key={button.buttonId}
            className={[styles.button, i === index ? styles.animate : null]
              .filter(Boolean)
              .join(" ")}
            id={button.buttonId}
            style={{
              top: button.y + "px",
              left: button.x + "px",
              backgroundColor: button.color
            }}
          ></button>
        );
      })}
    </div>
  );
}

Edit react-iterating-over-list-and-applying-animation-with-delay-between-each-iterati

Upvotes: 2

Oluwafemi Sule
Oluwafemi Sule

Reputation: 38982

To cleanup the timer in the useEffect, you must return a function.

The previous element index is 1 less the current index for non-zero indexes or the last element in the array of buttons when the current index is the first item.

    const prevElIndex = index == 0 ? props.map.length - 1 : index - 1;

Also, you need to check if the previous element has the animation class before toggling the class. This takes care of the first time the animation starts to run (the previous element would not have the animation class).

 if (
      document
        .getElementById(sequence[prevElIndex])
        ?.classList.contains(styles.animate)
    ) {
      document
        .getElementById(sequence[prevElIndex])
        ?.classList.toggle(styles.animate);
    }

Altogether, your effect would be along these lines:

  const [index, setIndex] = useState(0);
  const [sequence, setSequence] = useState(props.sequence);
  const [timer, setTimer] = useState<number>();

  useEffect(() => {
    const prevElIndex = index == 0 ? props.map.length - 1 : index - 1;
    if (
      document
        .getElementById(sequence[prevElIndex])
        ?.classList.contains(styles.animate)
    ) {
      document
        .getElementById(sequence[prevElIndex])
        ?.classList.toggle(styles.animate);
    }

    document.getElementById(sequence[index])?.classList.toggle(styles.animate);
    setTimer(setTimeout(() => setIndex((index + 1) % sequence.length), 3000));
    return () => clearTimeout(timer);
  }, [index]);

A working Stackblitz showing this in action.

Upvotes: 1

Related Questions