Uczymy się, jak możemy sterować wartościami wewnątrz RFC, które są tworzone za każdym renderem, ale to wcale nie oznacza, że zawsze muszą zwracać tę samą wartość. Do dzieła.

Academind ma na swoim Githubie bardzo fajny projekt. Rzućmy okiem na ten przykład:

import { useState, useCallback } from 'react';

import QUESTIONS from '../questions.js';
import Question from './Question.jsx';
import Summary from './Summary.jsx';

export default function Quiz() {
  const [userAnswers, setUserAnswers] = useState([]);

  const activeQuestionIndex = userAnswers.length;
  const quizIsComplete = activeQuestionIndex === QUESTIONS.length;

  const handleSelectAnswer = useCallback(function handleSelectAnswer(
    selectedAnswer
  ) {
    setUserAnswers((prevUserAnswers) => {
      return [...prevUserAnswers, selectedAnswer];
    });
  },
  []);

  const handleSkipAnswer = useCallback(
    () => handleSelectAnswer(null),
    [handleSelectAnswer]
  );

  if (quizIsComplete) {
    return <Summary userAnswers={userAnswers} />
  }

  return (
    <div id="quiz">
      <Question
        key={activeQuestionIndex}
        index={activeQuestionIndex}
        onSelectAnswer={handleSelectAnswer}
        onSkipAnswer={handleSkipAnswer}
      />
    </div>
  );
}

Mamy tutaj niby-stałe (const), ale wiemy, że każdy render (mount render i diff rendery) wywoła ponowne utworzenie tych stałych (albo innych zmiennych) i ich ponowną kalkulację.

Oznacza to, że const magicNumber = 42 byłoby przy każdym renderze tworzone od nowa, choć wartość byłaby w pewnym sensie stała.

Tu jednak przy każdym diff renderze, wywołanym zmianą stanu (dodanie nowej odpowiedzi przez setUserAnswers) te stałe będą tworzone od zera i przypisze się do nich nową wartość:

  • stała activeQuestionIndex zostanie utworzona od nowa i przypisze się do niej length z userAnswers
  • stała quizIsComplete zostanie utworzona od nowa i przypisze się do niej porównanie, czy ilość odpowiedzi jest równa ilości pytań (pytania importujemy z pliku js).

Ok, ale to jednak wygląda jeszcze jako tako logicznie:

  • Chcemy, aby coś zwracało jakąś stałą wartość (np. 42) – to przypisujemy stałą wartość
  • Chcemy, aby coś zwracało dynamiczną wartość – to przypisujemy dynamiczną wartość

Czasami jednak chcemy, aby przypisać stałą wartość, ale różną w zależności od tego, czy mamy mount render czy diff render i nawet różną w zależności od tego, jaki diff render mamy.

U tego samego twórcy i w tym samym projekcie mamy też taki komponent:

import { useState } from 'react';

import QuestionTimer from './QuestionTimer.jsx';
import Answers from './Answers.jsx';
import QUESTIONS from '../questions.js';

export default function Question({ index, onSelectAnswer, onSkipAnswer }) {
  const [answer, setAnswer] = useState({
    selectedAnswer: '',
    isCorrect: null,
  });

  let timer = 10000;

  if (answer.selectedAnswer) {
    timer = 1000;
  }

  if (answer.isCorrect !== null) {
    timer = 2000;
  }

  function handleSelectAnswer(answer) {
    setAnswer({
      selectedAnswer: answer,
      isCorrect: null,
    });

    setTimeout(() => {
      setAnswer({
        selectedAnswer: answer,
        isCorrect: QUESTIONS[index].answers[0] === answer,
      });

      setTimeout(() => {
        onSelectAnswer(answer);
      }, 2000);
    }, 1000);
  }

  let answerState = '';

  if (answer.selectedAnswer && answer.isCorrect !== null) {
    answerState = answer.isCorrect ? 'correct' : 'wrong';
  } else if (answer.selectedAnswer) {
    answerState = 'answered';
  }

  return (
    <div id="question">
      <QuestionTimer
        key={timer}
        timeout={timer}
        onTimeout={answer.selectedAnswer === '' ? onSkipAnswer : null}
        mode={answerState}
      />
      <h2>{QUESTIONS[index].text}</h2>
      <Answers
        answers={QUESTIONS[index].answers}
        selectedAnswer={answer.selectedAnswer}
        answerState={answerState}
        onSelect={handleSelectAnswer}
      />
    </div>
  );
}

Co tu się dzieje:

  • Initial state zostanie przypisany przy mountowaniu komponentu (selectedAnswer pusty string, isCorrect null)
  • Każda stała/zmienna tworzona od nowa przy mount render i diff renderach
  • Przy mount render do timer przypisane zostanie 1000ms, reszta warunków nie jest spełniona, więc timer ma 10000ms
  • Przy diff render, wywołanym zmianą stanu (np. ktoś ustawił selectedAnswer):
    • Tak samo jak przy mount render do timer przypisana zostanie wartość 10000ms
    • Jako że mamy już selectedAnswer, warunek pierwszego ifa jest spełniony i do timer przypisane zostanie 1000ms
    • Nie mamy jeszcze isCorrect innego niż null, więc następny if nie jest spełniony
  • Przy innym diff render, który ustali nam isCorrect program wykona:
    • Przypisz timer do 10000ms, robisz to przy każdym renderze
    • Warunek, aby selectedAnswer istniało spełniony, nadpisz timer na 1000ms
    • Warunek, aby isCorrect nie było nullem spełniony, nadpisz timer na 2000ms

To wszystko sprawia, że przy odrobinie pomyślunku jesteśmy w stanie sterować tym, jak ta wartość, tworzona od zera i wyliczana od zera przy każdym renderze, jest wyliczana.

Bo mamy komponent wyżej takie funkcje, które nie są tworzone od zera przy każdym renderze, bo mają useCallback:

 const handleSelectAnswer = useCallback(function handleSelectAnswer(
    selectedAnswer
  ) {
    setUserAnswers((prevUserAnswers) => {
      return [...prevUserAnswers, selectedAnswer];
    });
  },
  []);

  const handleSkipAnswer = useCallback(
    () => handleSelectAnswer(null),
    [handleSelectAnswer]
  );

  if (quizIsComplete) {
    return <Summary userAnswers={userAnswers} />
  }

  return (
    <div id="quiz">
      <Question
        key={activeQuestionIndex}
        index={activeQuestionIndex}
        onSelectAnswer={handleSelectAnswer}
        onSkipAnswer={handleSkipAnswer}
      />
    </div>
  );

I one są do question przekazywane jako onSelectAnswer i onSkipAnswer. Przez to, że jest useCallback one są tym samym obiektem, co sprawia, że diff render Quiz nie wywołuje diff-rendera Question…

A teraz w Question mamy funkcję, definiowaną przy każdym renderze RFC, ale nie powoduje to problemów z wydajnością ani ze zgodnością referencyjną, która wygląda tak:

function handleSelectAnswer(answer) {
    setAnswer({
      selectedAnswer: answer,
      isCorrect: null,
    });

    setTimeout(() => {
      setAnswer({
        selectedAnswer: answer,
        isCorrect: QUESTIONS[index].answers[0] === answer,
      });

      setTimeout(() => {
        onSelectAnswer(answer);
      }, 2000);
    }, 1000);
  }

Ta funkcja jest definiowana od zera przy każdym renderze RFC question, ale jest przekazywana do RFC Answers jako prop, zaś Answers nie używa useEffect i nie ma generalnie problemów, czy ta funkcja mu przekazywana to jest referencyjnie ten sam obiekt, czy tylko TAKI SAM obiekt (co w przypadku typów złożonych robi ogromną różnicę)

Więcej o tym w następnej lekcji, a teraz znowu do głównego tematu, co ta funkcja robi – ustawia selectedANswer na answer, czyli zmienia stan, wywołuje diff-render.

Jednocześnie kolejkuje kolejną zmianę stanu, gdzie już nie tylko selectedAnswer będzie ustawione, ale również isCorrect będzie inne niż null.

I wreszcie, kolejkuje wywołanie onSelectAnswer, przekazanej jako prop z komponentu wyżej. I teraz, jak wygląda dół RFC Question:

let answerState = '';

  if (answer.selectedAnswer && answer.isCorrect !== null) {
    answerState = answer.isCorrect ? 'correct' : 'wrong';
  } else if (answer.selectedAnswer) {
    answerState = 'answered';
  }

  return (
    <div id="question">
      <QuestionTimer
        key={timer}
        timeout={timer}
        onTimeout={answer.selectedAnswer === '' ? onSkipAnswer : null}
        mode={answerState}
      />
      <h2>{QUESTIONS[index].text}</h2>
      <Answers
        answers={QUESTIONS[index].answers}
        selectedAnswer={answer.selectedAnswer}
        answerState={answerState}
        onSelect={handleSelectAnswer}
      />
    </div>
  );
}

Kolejna zmienna (answerState) wyliczana przy każdym renderze RFC. I kolejne sterowanie tym poprzez odpowiednie warunki i nadpisywanie.

I wreszcie – komponenty dzieci. Answers olewamy na razie, key również (jeszcze to poznamy) natomiast patrzymy, że do timeout przekazujemy timer, zaś do onTimeout przekazujemy onSkipAnswer albo null.

Czyli gdzieś mamy RFC QuestionTimer, który, jeżeli nie wybraliśmy żadnej odpowiedzi, to po wykonaniu timeoutu ma po prostu skipnąć odpowiedź, zaś w przypadku innych timeoutów (odpowiedź wybrana, isCorrect ustawione) ma nic nie robić.

Ma nic nie robić, bo jakaś inna logika będzie sterowała tym, co się dzieje. Przeanalizujmy ten kod:

import { useState, useEffect } from 'react';

export default function QuestionTimer({ timeout, onTimeout, mode }) {
  const [remainingTime, setRemainingTime] = useState(timeout);

  useEffect(() => {
    console.log('SETTING TIMEOUT');
    const timer = setTimeout(onTimeout, timeout);

    return () => {
      clearTimeout(timer);
    };
  }, [timeout, onTimeout]);

  useEffect(() => {
    console.log('SETTING INTERVAL');
    const interval = setInterval(() => {
      setRemainingTime((prevRemainingTime) => prevRemainingTime - 100);
    }, 100);

    return () => {
      clearInterval(interval);
    };
  }, []);

  return (
    <progress
      id="question-time"
      max={timeout}
      value={remainingTime}
      className={mode}
    />
  );
}

Lekcja robi się przydługa, więc przeanalizujemy do końca w następnej lekcji.