Kontynuujemy lekcje poprzednie, generalnie poznajemy to, co czyni Reacta Reactem i bez zrozumienia tych konceptów nie ruszymy dalej. Do dzieła.

Podsumujmy pewne rzeczy:

  • mount render – wykonywany po tym, jak komponent robi mount
  • diff-render – inaczej re-render, wykonywany gdy zmieni się stan, zmieni się stan rodzica, zmieni się kontekst, rodzic robi diff-render
  • zmiana state wywołuje diff-render
  • podczas mount renderu i tylko wtedy ustawiany jest initial state
  • każda stała i zmienna w RFC jest tworzona od nowa i jej wartość przypisywana od nowa podczas każdego renderu
  • każda definicja funkcji wewnątrz RFC jest tworzona od nowa jako nowy obiekt z nową referencją, podczas każdego renderu
  • można stałe i zmienne ustrzec przed ponownym wyliczaniem używając useMemo i tablicy zależności
  • można funkcje ustrzec przed ponownym definiowaniem pod nową referencją poprzez useCallback i tablicę zależności
  • jeżeli rodzic-komponent ma diff-render, ale do RFC dziecka nie przekazano innych propsów, to memo może ustrzec to RFC przed triggerowaniem niepotrzebnego diff-rendera
  • useEffect bez tablicy zależności działa podczas każdego rendera (mount render i każdy diff-render)
  • useEffect z pustą tablicą zależności działa tylko podczas mount rendera
  • useEffect z pełną tablicą zależności działa podczas mount rendera i tych diff-renderów, podczas których doszło do zmiany jakiejkolwiek z zależności

Ok, przypomnę, omawiamy projekt z Githuba Academind (projekt Quizowy). Rzućmy okiem na ten RFC:

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}
    />
  );
}

I teraz tak:

  • Pierwszy useEffect wykona się podczs mount rendera oraz za każdym diff renderem, ale takim, podczas którego timeout albo onTimeout się zmienił
  • przed unmountem wykona się cleanup (clear interval)
  • przed diff-renderem, który mógłby triggerować ten useEffect cleanup również się wykona

Drugi useEffect:

  • Wykona się tylko podczas mount-rendera
  • Przed unmountem wykona się cleanup czyszczący interwał
  • Interwał sobie chodzi co 0.1 sekundy i ustawia stan na mniejszy o 0.1s od poprzedniego, to jest ten sam, jeden interwał, cały czas
  • Jeszcze tego nie znamy, ale za pomocą key można robić re-mount komponentu (wystarczy, że sobie to w głowie nakreślimy i już widzimy, jak bardzo skomplikowane i wymagające myślenia od nas wszystko się staje)

Ok, zobaczmy, co zwraca RFC Question:

 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>
  );

RFC Question zwara QuestionTimer:

  • Key oznacza, że jeżeli zmieni się timer (zmienna rfc question), odpalą się oba useEffecty
  • timeout jest przekazany jako timer
  • Do ontimeout przekazujemy albo onSkipAnswer, albo null

Jako że zmiana timera, który jest tu kluczem, powoduje nie re-render (diff-render) RFC QuestionTimer, nie musimy się przejmować za bardzo hookami useEffect, one mają cleanupy, na un-mount się wyczyszczą i jak każdy useEffect nie ma opcji, aby na kolejny mount się nie odpaliły.

Problemem może być to, co przekazujemy do onTimeout czasami, a zatem onSkipAnswer, które bierzemy z propsów i czasem przekazujemy jako onTimeout. Zobaczmy komponent dziecko:

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}
    />
  );
}

Jak mamy re-mount dziecka, bo zmienił się klucz przekazywany z góry (zmienna timer RFC Question) to problemu nie ma, na unmount wszystkie cleanupy się odpalają, na re-mount wszystkie hooki się odpalają na powrót.

Problem może stanowić onSkipAnswer, brany od dziadka (Quiz) i czasem przekazywany przez rodzica (Question) jako prop onTimeout do dziecka QuestionTimer.

Wyobraźmy sobie, że użytkownik nie wybrał żadnej odpowiedzi – w związku z czym nie zmienił się timer (omawialiśmy go w lekcji poprzedniej), nie zmienił się key, nie było żadnych re-mountów.

I okej, mamy naszego dziadka (Quiz):

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>
  );
}

Automatyczne skipnięcie odpowiedzi dodało nulla do stanu dziadka. I fajnie, jest okej. Ale teraz wyobraźmy sobie, że funckje handleSelectAnswer i handleSkipAnswer nie mają useCallback.

Oznacza to, że przy każdym renderze (w tym diff render wywołany zmianą stanu, np. mamy 1 odpowiedź więcej) są one definiowane od nowa. I stają się one nowymi obiektami (takimi samymi, ale nie tymi samymi). I gdzieś sobie idą w dół, dokładnie do komponentu QuestionTimer, przez komponent Question:

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}
    />
  );
}

Zaś onTimeout, do którego ładujemy skip answer jeżeli użytkownik nic nie wybierze na czas to zależność useEffect. I teraz tak – mamy diff render rodzica, wszystkie definicje funkcji stają się nowymi obiektami z nowymi referencjami (takimi samymi akurat, ale nie tymi samymi) i są przekazywane jako props do potomka nawet, nie dziecka.

Nawet gdyby potomek miał memo, to to go chroni przed diff-renderem wywołanym diff-renderem rodzica, ale przy tych samych propsach.

A teraz te propsy są inne! Ląduje taki sam, ale nie ten sam obiekt (funkcja), w związku z tym triggeruje się zmiana zależności useEffect polegająca na tej funkcji.

Może tego na pierwszy rzut oka nie widać tak, ale wbijmy sobie do głowy, że useCallback może być używane także do tego, aby do dziecka/potomka nie przekazywać nowego obiektu funkcji (takiego samego, ale nie tego samego), który triggeruje useEffect, który ma taką zależność i w naszym, ludzkim, high level programing paradigm myśleniu deklaratywnym ma sie uruchamiać, gdy funkcja się zmieni (tzn. jest inny obiekt).

Ale nie dociera do nas, że możemy dostać obiekt, który jest taki sam, ale nie ten sam i będzie to nam generować niechciane useEffecty. I do tego również służy useCallback, nie tylko do tego, aby keszować definicje funkcji, których definiowanie zajmuje dużo czasu zaś wiemy, że każda funkcja wewnątrz RFC jest definiowana od zera przy każdym renderze (mount i diff rendery).

Hook useCallback również służy do tego, aby utrzymać jednolitą referencję funkcji, która się nie zmienia, no chyba, że się zmienia w naszym tego słowa rozumieniu (to znaczy jest nową, inaczej działającą funkcją a nie taką samą ale z inną referencją).

Więcej Reacta niedługo.