Projekt roboczy, będzie quiz w całości to będzie tutorial w całości, na razie kodujemy wspólnie ten quiz. Timer już był pokazany, to teraz pokazujemy, co się zmieniło.

Ok, zanim zrobimy refactoring to App przyjmuje, czy pokazywać dobre odpowiedzi czy nie:

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App2 usingTimer={false} />
  </React.StrictMode>
);

Ok importy:

import LIST2 from './utils/list2';
import './App.css';

import { SelectableList3 } from './components/SelectableList3';
import { useState, useEffect } from 'react';
import { Question } from './components/Question';
import { Score } from './components/Score';
import { SimpleSummary } from './components/SimpleSummary';
import Timer from './components/Timer';

Question:

import LIST2 from "../utils/list2";

function Question({idx}){

    return (
        <h2>{LIST2[idx].question}</h2>
    )
}

export {Question};

Score:

function Score({score}){
    return (
        <p>Score: {score}</p>
    )
};

export {Score};

Summary:

import LIST2 from "../utils/list2"

function SimpleSummary({idx}){
    
    return (
        <>
        <p>Question {idx + 1} / {LIST2.length}</p>
        </>
    )
};

export {SimpleSummary};

Timer już omówiony, to teraz App. Nad app mamy funkcję pomocniczą:

function prepareAnswer(idx, answer){
    let questionNumber = idx;
    let chosenAnswer = answer;
    let isCorrect = LIST2[idx].correct === answer;

    return {questionNumber, chosenAnswer, isCorrect};
}

Ona formatuje odpowiedzi. Teraz stan App:

function App2({usingTimer}) {

  const [index, setIndex] = useState(0);
  const [score, setScore] = useState(0);
  const [answers, setAnswers] = useState([]);
  const [timerBlocked, setTimerBlocked] = useState(false);
  const maxIndex = LIST2.length;

Indeks, score, odpowiedzi, czy timer ma być zblokowany. Mamy też maxIndex.

Ok, setNextQuestion, już powoli zaczyna nabierać kształtu:

function setNextQuestion(ans){
    let answered = prepareAnswer(index, ans);
    setAnswers(prev => [...prev, answered]);
    if(answered.isCorrect === true){
        setScore((s) => s + 1);
    }
    if(index < maxIndex)
        setIndex((i) => i + 1);
    setTimerBlocked(false);
  }

SkipQuestion raczej proste:

function skipQuestion(){
    setAnswers(prev => [...prev, null]);
    if(index < maxIndex)
      setIndex((i) => i + 1);
  }

Debugujemy odpowedzi useEffectem:

useEffect(() => {
    console.log(answers);
  }, [answers]);
  
  console.log(index, maxIndex);

  let shouldRenderQuestion = true;
  if(index === maxIndex){
    shouldRenderQuestion = false;
  }

Od razu wrzuciłem takie coś, żebym mógł sprawdzać, czy mam wyświetlić pytanie. Po refaktorze to będą osobne komponenty, na razie trzeba było zrobić w ogóle funkcjonujący jako tako prototyp.

To teraz JSX:

return (
    <div className="App">
      {shouldRenderQuestion && usingTimer ?  
       <Timer 
       key={index+1} 
       timeout={5000} 
       onTimeout={timerBlocked ? null : skipQuestion} 
       blocked={timerBlocked}
      /> : null }
      <Score score={score} />
      {shouldRenderQuestion ? <Question idx={index} /> : null}
      {shouldRenderQuestion ? <SelectableList3 
      idx={index} 
      shuffle={true}
      key={index} 
      setTimerBlocked={setTimerBlocked}
      setNextQuestion={setNextQuestion } 
      skipQuestion={skipQuestion}
      showCorrect={true}/> : "Quiz Finished"}
      
      {shouldRenderQuestion ? <SimpleSummary idx={index} /> : null}
    </div>
  );
}

export default App2;

Jak już rozkminimy co jest co i dlaczego to pokazuję selectablelist:

import { useRef, useState, useEffect } from "react";
import LIST2 from "../utils/list2";
function SelectableList3({idx, shuffle, setNextQuestion, skipQuestion, setTimerBlocked, showCorrect}){

    const answers = useRef(null);

    const correct = useRef(null);

    const chosenAnswerRef = useRef(null);

    const [mode, setMode] = useState(null);
    
    if(correct.current === null){
        correct.current = LIST2[idx].correct;
    }

    if(answers.current === null){

        const rawAnswers = LIST2[idx].answers;
        const readyAnswers = [...Object.entries(rawAnswers)];

        if(shuffle){
            readyAnswers.sort(() => Math.random() - 0.5);
        }
        answers.current = [...readyAnswers];
    }

Chyba proste. SelectableList ma unmount po każdym (przez key na index) i to wiele ułatwia, bo te refy będą po każdym pytaniu nullem. Tu sobie przypisujemy odpowiedź prawidłową oraz odpowiedzi sobie parsujemy, mieszamy i zwracamy, już gotowe do używania.

ChosenAnswerRef ustawiamy na onClick zaś mode to mode, w effect zobaczymy. Warto też zwrócić uwagę, że bierzemy skipQuestion, setNextQuestion, oraz setTimerBlocked jako funkcje w propsach.

Mamy też showCorrect jako props, on pochodzi z App. Bez showCorrect nie ma pokazywania czy odpowiedź jest ok czy nie.

Dobra, to teraz zobaczmy effect:

 useEffect(() => {
        let timer;
        switch(mode){
            case null:
                break;
            case 'chosen':
                chosenAnswerRef.current.className = 'selectedAnswer';
                if(showCorrect === false){
                    setTimerBlocked(true);
                    timer = setTimeout(() => {
                        setNextQuestion(chosenAnswerRef.current.dataset.idx);
                    },700)
                    break;
                }
                
                setMode('waiting');
                setTimerBlocked(true);
                break;
            case 'waiting':
                timer = setTimeout(() => {
                let cls = chosenAnswerRef.current.dataset.idx === correct.current ? 'correctAnswer' : 'wrongAnswer';
                chosenAnswerRef.current.className = cls;
                setMode('feedbackSent')
                },2000);
                break;
            case 'feedbackSent':
                timer = setTimeout(() => {
                    setNextQuestion(chosenAnswerRef.current.dataset.idx);
                },1000);
                break;
            default:
                break;
        }

        return () => {
            clearTimeout(timer);
          };

    }, [mode, setNextQuestion, setTimerBlocked, showCorrect]);

Mega się namęczyłem, ale jestem zadowolony. Oczywiście nie zrozumiemy tego, jeżeli nie zobaczymy co tę maszynerię wprawia w ruch:

function handleClick(e){
        if(chosenAnswerRef.current === null){
            chosenAnswerRef.current = e.target;
            setMode('chosen');
        }
    }

No i teraz JSX, czyli najmniejsza trudność jak się okazuje:

    return (
        <> 
            <ul>
            {answers.current.map((item) => {
                const [key,value] = item;
               return (
               <li 
               key={key} 
               data-idx={key} 
               onClick={(e) => handleClick(e)}
               >{value}</li>)
            })}
            </ul>
            <button 
            disabled={mode !== null}
            onClick={() => skipQuestion()}
            >Skip</button>
        </>
    );
}

export {SelectableList3};

Co jest piękne:

  • Możemy przestawiać sobie czy ma pokazywać prawidłowe odpowiedzi czy nie
  • Możemy przestawiać, czy quiz ma być z timerem, czy nie
  • Możemy przestawiać, czy ma być shuffle, czy nie
  • Niezależnie od kombinacji naszych ustawień, wszystko działa jak należy, bez zarzutu

Co jest słabe:

  • Mało modularne, trzeba to będzie rozbić na komponenty, takie Quiz, Question, Answers, Timer i tak dalej
  • Nie mamy formularza i kontekstu jeszcze

Dodam, że poprzednio robiliśmy wszystko w kontekście i to w React Profiler pokazało, że co każdą tycią zmianę wszystko objęte kontekstem się re-renderowało (czyli cała apka).

Teraz bawimy się w useStejty i drillowanie oraz wynoszenie, to żadna hańba. Przyznam, że nie do końca to ogarniałem, ale generalnie context i inne hooki są po inne rzeczy. Nie ma tak, że context i reducer są lepsze od useState i drillowania.

Niedługo będzie cała apka, tworzymy razem albo czekamy na tutorial już bardziej poukładany.