Poprzednio poznaliśmy mocny hook useHttp, który pozwalał nam robić dosłownie wszystko. Tym razem 3 proste sposoby na useFetch, ugruntowanie wiedzy o programowaniu asynchronicznym i hookach Reacta.

Ok, zobaczmy jak do tematu podszedł WebDevSimplified, który rozbił to na dwa hooki, pierwszy to useAsync:

import { useCallback, useEffect, useState } from "react"

export default function useAsync(callback, dependencies = []) {
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState()
  const [value, setValue] = useState()

  const callbackMemoized = useCallback(() => {
    setLoading(true)
    setError(undefined)
    setValue(undefined)
    callback()
      .then(setValue)
      .catch(setError)
      .finally(() => setLoading(false))
  }, dependencies)

  useEffect(() => {
    callbackMemoized()
  }, [callbackMemoized])

  return { loading, error, value }
}

Co my tu mamy:

  • loading, error i value jako stan, pewien standard w takich wypadkach
  • chce, aby mu przekazać callback zwracający promise oraz tablicę zależności
  • memoizacja callbacka i wcale nie dlatego, że definiowanie go zajmuje dużo czasu i często będzie redefiniowany – celem utrzymania jedności referencyjnej definicji funkcji, która trafia do tablicy zależności useEffect
  • useEffect odpali się na initial render i wykona promisowy callback, gdyby ten callback się zmienił (zmieniła się któraś z jego zależności) to wykona efekt ponownie

Jak tego używamy:

import useAsync from "./useAsync"

export default function AsyncComponent() {
  const { loading, error, value } = useAsync(() => {
    return new Promise((resolve, reject) => {
      const success = false
      setTimeout(() => {
        success ? resolve("Hi") : reject("Error")
      }, 1000)
    })
  })

  return (
    <div>
      <div>Loading: {loading.toString()}</div>
      <div>{error}</div>
      <div>{value}</div>
    </div>
  )
}

Przesadnie się z tym przykładem nie postarał, ale widzimy o co chodzi. Tym niemniej, to tylko pierwszy hook, teraz useFetch zobaczmy:

import useAsync from "../9-useAsync/useAsync"

const DEFAULT_OPTIONS = {
  headers: { "Content-Type": "application/json" },
}

export default function useFetch(url, options = {}, dependencies = []) {
  return useAsync(() => {
    return fetch(url, { ...DEFAULT_OPTIONS, ...options }).then(res => {
      if (res.ok) return res.json()
      return res.json().then(json => Promise.reject(json))
    })
  }, dependencies)
}

Jeżeli solidnie przerobiliśmy poprzednie lekcje to wszystko będziemy rozumieli, w tym dlaczego:

  • default options jest powyżej RFC
  • o co chodzi z res.ok
  • dlaczego res.json też zwraca promise
  • dlaczego możemy chcieć pokazać error z jsona
  • można zwrócić reject przez Promise.reject

Całe piękno tego kodu polega na tym, że dostaniemy promise, która będzie albo fulfilled (res.ok) albo rejected, w nieco sztuczny sposób (Promise.reject) i z wiadomością o błędzie przekazaną przez api w JSON.

A co za tym idzie do tego, co zwraca ta funkcja można by było podpiąć then z dwoma callbackami, jeden na onFulfilled, drugi na onRejected, zaś w catch po prostu errory łapać (np. type error czy cokolwiek takiego).

Tutaj akurat tego nie mamy w useAsync (hook wyżej), ale ogólne podejście jest dobre.

Ok, a co zrobił Academind? Cóż, omawialiśmy już to (i jego dużo lepszy useHttp hook):

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

Tutaj memoizację callbacka on zrzuca niejako na nas (nie zawsze musimy też to robić, możemy zdefiniować funkcję powyżej RFC albo zaimportować z pliku poza komponentem).

Reszta to też jest dobry kod, który oddziela logikę try-catch dla potencjalnych błędów (jakiś error wynikający z throw Error) od logiki asynchronicznej, czyli async-await albo then(onResolved, onRejected)…

Tu mamy przykład funkcji fetchFn:

async function fetchSortedPlaces() {
  const places = await fetchAvailablePlaces();

  return new Promise((resolve) => {
    navigator.geolocation.getCurrentPosition((position) => {
      const sortedPlaces = sortPlacesByDistance(
        places,
        position.coords.latitude,
        position.coords.longitude
      );

      resolve(sortedPlaces);
    });
  });
}

Jest ta funkcja:

  • Powyżej poziomu RFC, autor na to pozwala, nie musi być memoizowana (gdyby jednak była w RFC, to sami o to zadbajmy)
  • Logika asynchroniczna jest tutaj przez nas pisana
  • Logika czegoś takiego, że błąd popełniliśmy wywołując np. toUpperCase na typie numerycznym (czyli ogólnie logika błędów, które mogą być i nie sposób co linijkę robić try catch, ale tych błędów, które możemy wywołać w funkcji asynchronicznej i nie przez to, że api nie działa), to znajduje się w try-catch i nie jest w żaden sposób mieszane z logiką asynchroniczną (ja osobiście nienawidzę Promise.catch, uważam to za głupotę, od rejectów mamy onRejected, od błędów mamy try-catch, zaś Promise.catch to takie nie wiadomo co do wszystkiego)

Ok, a jak do tematu podszedł 30 seconds of code? Czy w tym temacie (czyli hooka useFetch do robienia prostych fetch requestów z metodą GET) można jeszcze czymś zaskoczyć?

const useFetch = (url, options) => {
  const [response, setResponse] = React.useState(null);
  const [error, setError] = React.useState(null);
  const [abort, setAbort] = React.useState(() => {});

  React.useEffect(() => {
    const fetchData = async () => {
      try {
        const abortController = new AbortController();
        const signal = abortController.signal;
        setAbort(abortController.abort);
        const res = await fetch(url, {...options, signal});
        const json = await res.json();
        setResponse(json);
      } catch (error) {
        setError(error);
      }
    };
    fetchData();
    return () => {
      abort();
    }
  }, []);

  return { response, error, abort };
};

Można, po pierwsze używa obiektu React zamiast importów, ale to szczegół. Użył abort controllera, zatem i nam daje możliwość zrobić abort i też gdyby komponent używający tego hooka miał unmount (albo re-mount za pomocą zmiany key, też ten patent znamy) to ma cleanup.

Szczerze, to nie wiem dlaczego używa AbortControllera jako stan, albo to ja coś nie rozumiem, albo autor nie do końca rozumie czym jest abort controller.

Wystarczy użyć ref albo i niech będzie stan (z _ pod setAbort, to nie jest potrzebne, chodzi o memoizację pod kontem referencji). I potem możesz ten sam signal przypisywać do różnych requestów i abort ci będzie działać na wielu na raz, to nie musi być osobny abort controller do osobnego fetch requesta (albo do osobnego event listenera).

Swoją drogą to sam nawet nie wiem, czy nie dałoby się do jednego abort controllera przypisać 5 fetch requestów i 15 event listenerów i za jednym abort wszystko zatrzymać.

Także tak, wcale nie musi być, że jeden fetch request (albo event listener) musi mieć osobny abort controller. Natomiast jakoś on musi być memoizowany, żeby co render się nie zmieniał, opcje są takie:

  • powyżej RFC lub w tym wypadku hook function, bez sensu, bo będzie współdzielony przez wszystkie komponenty używające tego hooka
  • ref, który sprawdza, czy ref.current jest pusty i przypisuje abort controller, którego potem użyjemy – całkiem spoko opcja
  • const [abortController, _] = useState(() => new AbortController()) – też jakaś opcja
  • olać stan, ale w useEffect, powyżej poziomu definicji funkcji fetchData, zrobić const z abort controllerem, wtedy cleanup będzie miał do niego dostęp

Ok, więcej Reacta niedługo, te tematy nie powinny być dla nas w żadnym stopniu trudne, jak są – musimy powtórzyć poprzedni materiał.