Omawiamy dość ciekawy przykład flow komponentów Reacta i ich dobrej kompozycji w złożonym projekcie. Zakładam, że pewne podstawy już znamy i poprzedni materiał przerobiliśmy.

Przykład z Githuba academind, nawet komponent, który już omawialiśmy:

import { useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';

function Modal({ open, children, onClose }) {
  const dialog = useRef();

  useEffect(() => {
    if (open) {
      dialog.current.showModal();
    } else {
      dialog.current.close();
    }
  }, [open]);

  return createPortal(
    <dialog className="modal" ref={dialog} onClose={onClose}>
      {open ? children : null}
    </dialog>,
    document.getElementById('modal')
  );
}

export default Modal;

Dodam, że open to prop RFC modal, nie ma żadnego związku z atrybutem open HTMLDialogElement, którego nie powinniśmy używać, bo jest problematyczny (wszystkie atrybuty bez wartości są, plus backdrop też się nie wyświetla).

Powinniśmy zawsze używać metod close, show albo showModal (to drugie pokazuje tło, które w CSS możemy ostylować) i tak tu robimy w zależności od propsa. Mamy też zwykły ref do elementu HTML (mam nadzieję, że forwardRef też pamiętamy, gdybyśmy chcieli aby rodzic robił ref do komponentu, tak samo useImperativeHandle, gdybyśmy chcieli pozwolić rodzicowi tylko na pewne modyfikacje elementu html w JSX dziecka, przechwytując ref od rodzica w tym hooku i zapewniając publiczne metody pod prywatnym refem).

Ok, mam nadzieję, że to wszystko pamiętamy, podobnie jak create portal (który za selektor bierze element z index.html) i wszystkie implikacje (komponent RFC nadal jest dzieckiem swojego rodzica, potomkiem swoich przodków, tylko renderuje się gdzie indziej), mam nadzieję, że children też ogarniamy (sloty w React działają pod children.props).

Ok, wszystko fajnie, teraz gdzie ten modal jest użyty. Fragment JSXa z App.js:

<Modal open={modalIsOpen} onClose={handleStopRemovePlace}>
        <DeleteConfirmation
          onCancel={handleStopRemovePlace}
          onConfirm={handleRemovePlace}
        />
      </Modal>

I teraz tak:

  • modalIsOpen to state z useState
  • onClose to funkcja zdefiniowana w RFC App (bez useCallback)
  • Jako dziecko komponent DeleteConfirmation
    • onCancel to ta sama funkcja w RFC App (bez useCallback)
    • onConfirm to funkcja w RFC App z useCallback

Co nam mówi użycie useCallback? Albo to, że funkcja jest bardzo, bardzo wolna (tj. proces jej definiowania jest bardzo wolny), albo to, że gdzieś jest zależnością i wymuszamy, aby miała jedną i tą samą referencję (chyba że jej zależności się zmienią). To, mam nadzieję, też pamiętamy.

Zobaczmy ten RFC:

import { useEffect } from 'react';

import ProgressBar from './ProgressBar.jsx';

const TIMER = 3000;

export default function DeleteConfirmation({ onConfirm, onCancel }) {
  useEffect(() => {
    const timer = setTimeout(() => {
      onConfirm();
    }, TIMER);

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

  return (
    <div id="delete-confirmation">
      <h2>Are you sure?</h2>
      <p>Do you really want to remove this place?</p>
      <div id="confirmation-actions">
        <button onClick={onCancel} className="button-text">
          No
        </button>
        <button onClick={onConfirm} className="button">
          Yes
        </button>
      </div>
      <ProgressBar timer={TIMER} />
    </div>
  );
}

No tak, gdyby każdy rerender App.js powodował definiowanie od nowa funkcji przekazywanej jako prop onConfirm, to za każdym razem odpalałby się wtedy useEffect, który ma tę funkcję w zależnościach.

Zobaczmy useCallback w RFC App:

 const handleRemovePlace = useCallback(
    async function handleRemovePlace() {
      setUserPlaces((prevPickedPlaces) =>
        prevPickedPlaces.filter(
          (place) => place.id !== selectedPlace.current.id
        )
      );

      try {
        await updateUserPlaces(
          userPlaces.filter((place) => place.id !== selectedPlace.current.id)
        );
      } catch (error) {
        setUserPlaces(userPlaces);
        setErrorUpdatingPlaces({
          message: error.message || 'Failed to delete place.',
        });
      }

      setModalIsOpen(false);
    },
    [userPlaces, setUserPlaces]
  );

To jest z jednej strony proste, bo tematy te przerabialiśmy bardzo ostro, z drugiej nie pokazuję całego kodu, zmuszam czytelnika, aby niejako domyślił się i wyobraził sobie to, czego nie widzi (dlatego zawsze w takich przykładach staram się bazować na naprawdę dobrym kodzie).

Więc tak, chcemy czasem tę funkcję definiować od nowa, gdy userPlaces albo setUserPlaces się zmienią. Ale nie definiujemy jej od nowa wtedy, gdy ktoś zmieni tryb z jasnego na ciemny, na przykład.

Ok, rzućmy okiem na RFC progress bar:

import { useState, useEffect } from 'react';

export default function ProgressBar({ timer }) {
  const [remainingTime, setRemainingTime] = useState(timer);

  useEffect(() => {
    const interval = setInterval(() => {
      setRemainingTime((prevTime) => prevTime - 10);
    }, 10);

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

  return <progress value={remainingTime} max={timer} />;
}

Mam nadzieję, że ogarniamy:

  • Będzie jeden interwał, bo timer przekazany do propsów pochodzi z DeleteConfirmation RFC, a zdefiniowany jest powyżej tego RFC i jedyny sposób, aby zmienić timer to by było re-mountować ProgressBar poprzez key, jak już wyświetlisz DeleteConfirmation z progess barem to nawet jakby się DeleteConfirmation rerenderował, nie ma to znaczenia, props będzie i pod względem wartości i referencji taki sam oraz ten sam
  • Będzie jeden interwał, bo pusta tablica zależności, przed unmountem będzie jeden raz cleanup chodził, i tyle. Nawet rodzic może się rerenderować, powyżej RFC rodzica jest ten timer, nie ma opcji, żeby się popsuło
  • Interwał jeden, ale re-renderów będzie dużo, wykonywanych przez ten interwał, który bawi się setState z updater function (dzięki czemu nie mamy state w zależnościach i nie ma pętli nieskończonej). Każdy re-render pokaże już value progressu odpowiednio zmienione.

Nie wiem, czy pamiętamy useFetch, które omawialiśmy, ale pochodzi ono z tego samego githuba, z tego samego projektu nawet:

import { useEffect, useState } from 'react';

export function useFetch(fetchFn, initialValue) {
  const [isFetching, setIsFetching] = useState();
  const [error, setError] = useState();
  const [fetchedData, setFetchedData] = useState(initialValue);

  useEffect(() => {
    async function fetchData() {
      setIsFetching(true);
      try {
        const data = await fetchFn();
        setFetchedData(data);
      } catch (error) {
        setError({ message: error.message || 'Failed to fetch data.' });
      }

      setIsFetching(false);
    }

    fetchData();
  }, [fetchFn]);

  return {
    isFetching,
    fetchedData,
    setFetchedData,
    error
  }
}

I to useFetch użyto w App.js i pod aliasem userPlaces, setUserPlaces dla fetchedData, setFetchedData użyto w tej funkcji z useCallback. Tak wyglądają bardziej złożone projekty Reacta i dopiero ta złożoność weryfikuje, czy naprawdę rozumiemy wszystko tak jak nam się wydaje, czy może powinniśmy np. skorzystać z frameworku typu Angular, gdzie ciężko jest cokolwiek zacząć i otrzymać natychmiastową gratyfikację, za to nazwy metod mówią nam co i kiedy się stanie i mniej nas to zaskakuje, niż React, w którym wszyscy „potrafią” pisać kod.