Home > Back-end >  Force state update/re-render despite value being the same - React
Force state update/re-render despite value being the same - React

Time:11-21

Context

I am making a Quiz Application in React using Typescript. Every quiz is composed by a question, four options, and a time bar that shows the remaining time the user has to answser the question.

The part of the application I am working on (the quizzes) is made up of two main components: Game and Question.

Game is the component responsible for storing the game information (name of the game, participants, etc.) and passing it to the question through a context (GameContext), as well as it is responsible for some styles of the page.

Question is the component that contains the question to be answered by the user. It accepts three parameters: title, options, and time, where title is the question itself, options is an object containing the options the user can select to answer the question, and time is the time the user will have to answer the question.

The time limit the user has to answer the question is showed as a shrinking bar (I call it 'the time bar'), and it is a custom component: Timebar (this is where my problem begins).

React components and code involved

This is my Game component:

import { useContext, useEffect, useState } from 'react';
import styles from './Game.module.scss';
import socket from '../../../services/socket';
// Context
import GameContext from '../Game.context';
// Components
import Question from './components/Question/Question.component';

function Game() {
    const gameContext = useContext(GameContext)

    useEffect(() => {
        socket.once('game:finish_question', ({ wasCorrectAnswer, correctAnswers }) => {            
            // Highlight correct answers and emit a 'game:next_question' event.
        });

        socket.once('game:update_question', ({ newQuestion }) => {
            gameContext.setGameInformation(current => {
                return {...current, currentQuestion: newQuestion};
            });
        });
    }, []);

    return (
        <div className={ styles.container }>
            <div className={ styles['question-container'] }>
                <Question 
                    title={ gameContext.gameInformation.currentQuestion.title }
                    options={ gameContext.gameInformation.currentQuestion.options }
                    time={ gameContext.gameInformation.currentQuestion.time }
                />
            </div>
        </div>
    )
}

export default Game;

This is my Question component:

import { useContext, useEffect, useState, useRef } from 'react';
import styles from './Question.module.scss';

import socket from '../../../../../services/socket';
import GameContext from '../../../Game.context';

// Components
import Timebar from '../Timebar/Timebar.component';

interface QuestionProps {
    title: string;
    options: {
        text: string,
        isCorrect: boolean
    }[];
    time: number;
    showCorrectOptions: boolean;
}

function Question({ title, options, time }: QuestionProps) {
    const gameContext = useContext(GameContext);
    const option_colors = ['red', 'blue', 'yellow', 'green'];
    const option_numerals = ['A', 'B', 'C', 'D'];
    const [ selectedOption, setSelectedOption ] = useState<number>(-1);

    function submitAnswer(option_index: number) {
        socket.emit('player:submit_answer', {
            gameId: gameContext.gameInformation.id,
            optionId: option_index
        });

        setSelectedOption(option_index);
    }

    function finishQuestion() {
        socket.emit('player:finish_question', {
            gameId: gameContext.gameInformation.id
        });
    }

    function nextQuestion() {
        socket.emit('player:next_question', {
            gameId: gameContext.gameInformation.id
        });
    }

    return (
        <div className={ styles.container }>
            <div className={`${ styles.title } py-5`}>
                <h1>{ title }</h1>
            </div>
            <div className={ styles['timebar-container'] }>
                <Timebar duration={ time } />
            </div>
            <div className={ styles.options }>
            {
                options.map((option, i) => {
                    let background = option_colors[i];

                    return (
                        <button className={ styles.option } style={{ background: background}} onClick={() => submitAnswer(i)}>
                            <div className={ styles.numeral }><span>{ option_numerals[i] }</span></div>
                            <div className={ styles.text }>{ option.text }</div>
                        </button>
                    )
                })
            }
            </div>
            <button onClick={finishQuestion} className="btn btn-success w-100">Finish Question</button>
            <button onClick={nextQuestion} className="btn btn-info w-100">Next Question</button>
        </div>
    )
}

export default Question;

And this is my Timebar component:

import { CSSProperties, useEffect, useRef } from 'react';
import styles from './Timebar.module.scss';


interface TimebarProps {
    duration: number,
    rounded?: boolean,
    style?: CSSProperties,
    color?: string,
    paused?: boolean
}

function Timebar({ duration, rounded=false, paused=false }: TimebarProps) {

    function restartTimebar() {
        if (!timebar.current) return;
        // Restart CSS animation
        timebar.current.classList.remove(styles['animated-timebar']);
        void timebar.current.offsetWidth;
        timebar.current.classList.add(styles['animated-timebar']);
    }

    useEffect(() => {
        console.log('The time changed!:', duration);
        restartTimebar();
    }, [duration]);
    return (

        <div className={ styles.timebar }> //Timebar container
            <div style={{ animationDuration: `${duration}s`, background: color}}></div> // Shrinking progress bar
        </div>
    )
}

export default Timebar;

and its styles (Timebar.module.scss):

.timebar {
    width: 100%;
    overflow: hidden;
    padding: 0;
    margin: 0;
}

.timebar div {
    height: 10px;
    background-color: $danger;
}

.animated-timebar {
    animation-name: timebar;
    animation-fill-mode: forwards;
    animation-timing-function: linear;
    transform-origin: left center;
}

@keyframes timebar {
    from {
        transform: scaleX(1);
    }
    to {
        transform: scaleX(0);
    }
}

Problem

The situation is the following: Imagine we have a question for which time is 10 (seconds). First, we update the currentQuestion attribute of the GameContext, then, because of this, after passing this value to the Question component and then to the Timebar component, the time bar will begin its animation.

Now, imagine the user answers in 5 seconds, so we update the currentQuestion attribute of the GameContext with the following question. The thing is that, if the time of the next question is also 10 seconds, the time attribute of currentQuestion is not going to trigger a state update, and therefore, the Timebar component won't re-render (will not restart its animation) which is a big problem.

What I have tried

I tried looking for a way to force a re-render, but I couldn't find one for functional components.

I also tried creating a state variable inside Question component called timebarTime like this: [timebarTime, setTimebarTime] = useState(time), pass it as value to the duration parameter of Timebar component, and then, I would add a useEffect like this one in Question:

// Everytime we change the question, we restart the states
useEffect(() => {
    // Set timebarTime to an impossible value...
    setTimebarTime(-1);
    // And then change it again to the value I want (does not work)
    setTimebarTime(time);
}, [title, options, time]);

Does anyone know the solution to this confusing problem? In advance, thanks a lot for your help!

CodePudding user response:

You may use a keyed component.

<Timebar key={questionId} duration={time} />

The key is intentionally used to get a fresh instance of the Timebar component. It looses all internal state. Meaning that it doesn't update, it does an unmount mount.

I presume you do have a question ID available somewhere in the context, so it should be fine to use that. In fact, you do want to timer to always restart when the question changes, so this should feel natural.

If you do not have a question ID, you can use anything else that changes with a question, maybe the title would be good enough if you do not expect to have two questions with the same title.

  • Related