Search code examples
reactjsreact-hooksstateshuffle

Shuffling items in array when user click over them - React JS


Newbie here; I have a <QuizPage /> component with a child component called <TriviaObject /> which holds three properties; a question, a correct answer with a string value, and an array of incorrect answers. a JSON sample of how the API returns the data model:

{
 question: "question",
 correct_answer: "answer",
 incorrect_answers: [ "answer1", "answer2", "answer3", ... ]

The issue is, when I click over an answer, the whole array of answers shuffles on the screen. Alla I need to achieve is clicking on an answer and store it into a state, in order to submit the selection of answers and show how many were correct. Interesting fact, after pasting console.log almost everywhere, nothing returns on console which makes me wonder if either state or effect are working correctly.

quiz is my state array that renders a series of TriviaObject components, and the problem began once I have implemented a new state userSelection to store the user answer for each question.

So far this is what I have done:

export default function QuizPage() {
    const [quiz, setQuiz] = useState([]);
    // state to store user's selected answer for each question
    const [userSelection, setUserSelection] = useState([])

    useEffect(() => {
        async function getData() {
            try {
                const res = await fetch("https://opentdb.com/api.php?amount=10")
                const data = await res.json()
                setQuiz(data.results)
            } catch (error) {
                console.error("Error fetching data:", error)
            }
        }
        getData()
    }, [])
    
    if (!quiz || quiz.length === 0) {
        return <div>Loading...</div>; // or any loading indicator
    }

    const handleAnswerSelected = (answer, index) => {
        console.log(`Selected Answer for Question ${index + 1}:`, answer)
        // update the userSelection state array with the selected answer
        const updatedSelection = [...userSelection];
        updatedSelection[index] = answer;
        setUserSelection(updatedSelection);
        console.log("Update userSelection:", updatedSelection)
    }

    const renderTrivia = quiz.map((object, index) => {
        console.log("Rendering TriviaObject with props:", object)
        return (
        <TriviaObject
            key={index}
            question={decodeHTML(object.question)}
            correct_answer={decodeHTML(object.correct_answer)}
            incorrect_answers={object.incorrect_answers.map(item => decodeHTML(item))}
            onAnswerSelected={(answer) => handleAnswerSelected(answer, index)}
            
        />
    )})

    return (
        <div className="quiz-page">
        {renderTrivia}
        <button>Submit Answers</button>
        </div>
    )
}

Below, the child component <TriviaObject />. I have declared a state allAnswers to be populated by the mix of right and wrong answers fetched from the API, implemented a shuffle logic, and deconstructed the props object to take off the correct and incorrect answers, shuffle them, and allow me to access their index in order to store them in the userSelection state array. I have checked in the React Dev Tools Components and every time I click over an answer is correctly stored in the userSelection state array.


export default function TriviaObject(props) {
    const { question, onAnswerSelected, correct_answer, incorrect_answers } = props;

    const [allAnswers, setAllAnswers] = useState([]);

    // Apply shuffle only once when the component mounts
    useEffect(() => {
        console.log("shuffling answers...");
        const shuffledArray = shuffle([correct_answer, ...incorrect_answers]);
        setAllAnswers(shuffledArray);
    }, [correct_answer, incorrect_answers]);
    
    // Fisher-Yates shuffle alrgorithm
    function shuffle(array) {
        const shuffledArray = [...array];
        for (let i = shuffledArray.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            const temp = shuffledArray[i];
            shuffledArray[i] = shuffledArray[j];
            shuffledArray[j] = temp;
        }
        return shuffledArray;
    }

    return (
    <div className="trivia-obj">
        <h2>{question}</h2>
        <ul>
        {allAnswers.map((answer, index) => {
            const styles = { 
                backgroundColor:
                    onAnswerSelected[index] === answer ? '#D6DBF5' : '' ,
                border:
                    onAnswerSelected[index] === answer
                        ? 'none'
                        : '1px solid #D6DBF5'
            };
            return (
                <li
                    key={index}
                    onClick={() => onAnswerSelected(answer)}
                    style={styles}
                >
                    {answer}
                </li>
            );
        })}
        </ul>
    </div>
);
}

I have been looping over this issue for the past few days and I am going nowhere. I am on the learning stage of React Basics, so please, apologise me in advance if the code looks ugly but I genuinely wish to learn from any feedback of experienced React dev in here. Many thanks in advance for your time to read my request.

After initialising userSelection state array to store the selected answer for each question, I have created the handleAnswerSelected function which is fired up from a listener onAnswerSelected. Doing this, I can pass the listener prop to the child component, for which takes the selected answer and update the userSelection. I have tried implementing useMemo() hook to save both correct_answer and incorrect_answers in a variable and increase performance after shuffling this way:

export default function TriviaObject(props) {
    const { question, onAnswerSelected } = props;
    const { correct_answer, incorrect_answers } = useMemo(() => props, [props]); // Memoizing props
    const memoizedAnswers = useMemo(() => [correct_answer, ...incorrect_answers], [correct_answer, incorrect_answers]);
    const [allAnswers, setAllAnswers] = useState([])

return(
// JSX here
)
}

Although it improved the performance, It did not change the final result.


Solution

  • So your problem is right here:

    // Apply shuffle only once when the component mounts
        useEffect(() => {
            console.log("shuffling answers...");
            const shuffledArray = shuffle([correct_answer, ...incorrect_answers]);
            setAllAnswers(shuffledArray);
        }, [correct_answer, incorrect_answers]);
    

    What you are doing is saying "any time correct answer changes, or incorrect answer changes, shuffle the list of answers". Which on the surface seems fine. Except in javascript [1, 2, 3] !== [1, 2, 3]. Because lists are objects. So lists with the same contents are not equal, only lists that are actually the exact same list are equal.

    Now, where does the list come from? It comes from the props in the parent component. Where the list is generated from JSON every render cycle. So every time the parent component (Quiz page) re-renders, the child component (Trivia Object) gets a new list that just happens to contain identical elements. This means that it runs the use-effect again, shuffling all the answers around.

    Below is a fix:

    // Apply shuffle only once when the component mounts
       useEffect(() => {
           console.log("shuffling answers...");
           const shuffledArray = shuffle([correct_answer, ...incorrect_answers]);
           setAllAnswers(shuffledArray);
       }, [correct_answer, ...incorrect_answers]);
    

    now you are comparing a bunch of different strings to a bunch of different strings, something javascript is good at.

    The reason you didn't get this problem until now is that the parent component previously only re-rendered when changing the triviaObjects and therefore shuffling would have been expected anyway. Now it is re-rendering whenever you change your selected answers, which causes this shuffling to happen.

    I can't be 100% sure that this will work, but it should at least help. Let me know :)

    Some advice when dealing with these kinds of bugs: first commit everything so it's all saved. Then go in with a chainsaw and remove as much code as you can without removing the bug. Seems counterintuitive, but most bugs are actually fairly simple when you get right down to it, it's just the complexity surrounding them that makes them so good at hiding. Once you have the simplest example, you can figure out a fix, git reset to go back to the original code, and then implement the fix.