Poznajemy przykład bardzo dobrze napisanego custom hooka – useHttp z pewnego projektu w internecie, który już nieco omawialiśmy. Do dzieła.

Ok, rzućmy okiem najpierw na RFC, który tego hooka używa:

import MealItem from './MealItem.jsx';
import useHttp from '../hooks/useHttp.js';
import Error from './Error.jsx';

const requestConfig = {};

export default function Meals() {
  const {
    data: loadedMeals,
    isLoading,
    error,
  } = useHttp('http://localhost:3000/meals', requestConfig, []);

  if (isLoading) {
    return <p className="center">Fetching meals...</p>;
  }

  if (error) {
    return <Error title="Failed to fetch meals" message={error} />;
  }

  // if (!data) {
  //   return <p>No meals found.</p>
  // }

  return (
    <ul id="meals">
      {loadedMeals.map((meal) => (
        <MealItem key={meal.id} meal={meal} />
      ))}
    </ul>
  );
}

Wnioski:

  • Request config powyżej poziomu RFC, aby zawsze miał jednakową referencję, bo inaczej każdy re-render meals będzie definiował pusty obiekt od nowa, a dla custom hooków argumenty do nich przekazywane stanowią zależność
  • Pusta tablica zamiast const pustaTablica powyżej RFC sugeruje, że ta pusta tablica zostanie użyta jako initial state w useState, zatem nie mamy tego problemu co przy requestConfig, który zapewne będzie używany w useEffect albo czymś z tablicą zależności, inaczej autor aż tak by się nie bawił
  • Możemy wywnioskować, że hook używa trzech stanów – data, isLoading, error, dla każdego state i setState, ale setState obsługuje ten hook
  • Możemy założyć, że za asynchroniczną obsługę zaciągania odpowiada useEffect, ale jego zależności nie są puste (tak jak by był w komponencie) tylko mają config oraz url, jeżeli one się nie zmienią to będzie zaciągane jeden raz
  • Nazwa useHttp oraz fakt przekazywania configu sugeruje, że ten hook obsługuje coś więcej niż tylko proste get requesty, pamiętajmy zatem, że body w configu zawsze musi być obiektem JS przepuszczonym przez JSON stringify (co w efekcie daje nam string zapisany w formacie JSON)

Ok, fajnie, zaraz zobaczymy ten hook. Najpierw mamy prywatną dla tego pliku (nieeksportowaną przez useHttp.js) funkcję httpRequest:

import { useCallback, useEffect, useState } from 'react';

async function sendHttpRequest(url, config) {
  const response = await fetch(url, config);

  const resData = await response.json();

  if (!response.ok) {
    throw new Error(
      resData.message || 'Something went wrong, failed to send request.'
    );
  }

  return resData;
}

Mam nadzieję, że pamiętamy jak działa await. Tutaj warto wspomnieć, że:

  • Żeby był await, funkcja musi być async
  • RFC oraz callbacki do np. useEffect nie mogą być async
  • Z innej beczki – IIFE mogą być w JS async, async modules mogą mieć tzw. top-level await
  • Choć async await zastąpił poniekąd promises, to pamiętajmy, że awaity wykonują się sekwencyjnie. Najpierw jeden await, potem drugi, to nie jest odpowiednik Promise.all, Promise.allSettled, gdzie kilka asynchronicznych akcji rusza jednocześnie, tutaj drugi await czeka na pierwszy i nie zawsze async await jest lepszy od promises!
  • Inna rzecz – response.ok można by było już po pierwszym awaicie sprawdzić, a swoją drogą awaity są dwa, bo response.json() też zwraca promise
  • Jakbyśmy nie mieli funkcji async to możemy zrobić fetch bez await i dopiąć do niego then, response.json i kolejny then
  • Swoją drogą nigdy przenigdy nie robimy callbacków useEffect z async, ale wewnątrz callbacku możemy zdefiniować i wywołać funkcję async

Ok, skoro mamy to wszystko już wyjaśnione, to przedstawiam główny hook:

export default function useHttp(url, config, initialData) {
  const [data, setData] = useState(initialData);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState();

  function clearData() {
    setData(initialData);
  }

  const sendRequest = useCallback(
    async function sendRequest(data) {
      setIsLoading(true);
      try {
        const resData = await sendHttpRequest(url, { ...config, body: data });
        setData(resData);
      } catch (error) {
        setError(error.message || 'Something went wrong!');
      }
      setIsLoading(false);
    },
    [url, config]
  );

  useEffect(() => {
    if ((config && (config.method === 'GET' || !config.method)) || !config) {
      sendRequest();
    }
  }, [sendRequest, config]);

  return {
    data,
    isLoading,
    error,
    sendRequest,
    clearData
  };
}

Co my tu mamy:

  • Wcześniej już wydedukowane przez nas stany, czyli data, isLoading, error, wszystkie mają state i setState, ale tylko state jest zwracane przez ten hook, setState zajmuje się hook
  • funkcja clearData, w niektórych przypadkach użycia jest potrzebna, zwracamy ją jako ostatni argument, bo czasem się przyda użytkownikowi hooka, ale nie tak często
  • funkcja async, która logikę do robienia await fetch, resposne.json i sprawdzanie response.ok wydelegowała do funkcji wyżej, a sama z niej korzysta tylko od strony tej logiki reactowo-hookowej
  • czyli nasza funkcja ustawia loading na true, próbuje wykonać request, jednocześnie mergując istniejący config z body (które jest obiektem JSON ale to już problem naszego użytkownika, żeby przekazał tak), swoją drogą to body to jest argument do niej i ta funkcja też jest zwracana przez clearData
  • ta funkcja musi mieć useCallback, aby nie była definiowana od nowa z nową referencją bez potrzeby, bo używamy tej funkcji w useEffect jako zależność, a sama funkcja zmienia stan, a zmiana stanu to jest re-render, definicja od zera funkcji w RFC bez useCallback i taka pętla nieskończona
  • useEffect omówimy za chwilę

Czyli teraz. Zaraz zaraz skoro zwracamy sendRequest do dlaczego go używamy w useEffect? Cóż, zwracamy możliwość wywołania sendRequest dla requestów innych niż get (jeszcze użytkownik niech pamięta, aby jako argument sendRequest przekazał obiekt JS przepuszczony przez JSON stringify bo to do body idzie).

Ta cała pokraczna logika (związana z tym, że config w fetch api jest opcjonalny, method w config jest opcjonalna, zaś domyślna wartość to GET) ma za zadanie sprawdzić, czy mamy get czy inną metodę i jeżeli get to samemu od razu na wstępie załadować co trzeba. Funkcja sendRequest jest tu też bez argumenty wywołana, body będzie undefined, czyli tak, jak powinno wyglądać body w metodzie get.

Zaś jeśli inna metoda – to użytkownik niech sobie sam tę funkcję wywoła, jeszcze do body odpowiedni JSON przekaże. Tylko referencja metody sendRequest nie może się zmieniać doprowadzając useEffect do pętli nieskończonej, stąd useCallback.

Oto przykład komponentu korzystającego z useHttp do robienia metody post, powinniśmy bez problemu już wszystko rozumieć:

const requestConfig = {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
};

export default function Checkout() {
  const cartCtx = useContext(CartContext);
  const userProgressCtx = useContext(UserProgressContext);

  const {
    data,
    isLoading: isSending,
    error,
    sendRequest,
    clearData
  } = useHttp('http://localhost:3000/orders', requestConfig);

  const cartTotal = cartCtx.items.reduce(
    (totalPrice, item) => totalPrice + item.quantity * item.price,
    0
  );

  function handleClose() {
    userProgressCtx.hideCheckout();
  }

  function handleFinish() {
    userProgressCtx.hideCheckout();
    cartCtx.clearCart();
    clearData();
  }

  function handleSubmit(event) {
    event.preventDefault();

    const fd = new FormData(event.target);
    const customerData = Object.fromEntries(fd.entries()); // { email: test@example.com }

    sendRequest(
      JSON.stringify({
        order: {
          items: cartCtx.items,
          customer: customerData,
        },
      })
    );
  }