React-Router v6.4 jako menedżer stanu

13 lipca, 2022 Dawid Buliński

React-router to biblioteka umożliwiająca tworzenie routingu po stronie klienta. Jednak dzięki nowemu API może ona stać się również świetną alternatywą dla globalnych menedżerów stanu. Zobacz projekt stworzony w oparciu o taką koncepcję!

Od wielu lat w świecie Reacta (i nie tylko) pojawiają się coraz to nowsze koncepcje zarządzania stanem aplikacji. Skoro dostępnych jest tak wiele świetnych narzędzi, czy w związku z tym warto przyglądać się kolejnym rozwiązaniom? Moim zdaniem tak! Zwłaszcza, że być może nie potrzebujecie żadnej dodatkowej biblioteki. Dzięki nowemu API react-routera mamy możliwość definiowania funkcji loader i action na poziomie poszczególnych ścieżek. Aby zademonstrować jego możliwości, stworzono dwie wersje jednej aplikacji:

– z użyciem tylko hooków dostarczanych przez Reacta, bez routingu. Kod źródłowy możecie znaleźć tutaj, a wersję live tutaj.
– z użyciem nowego React-Routera v6.4 i React 18. Kod źródłowy możecie znaleźć tutaj, a wersję live tutaj

 

Projekt

 

Projekt, który został stworzony na potrzeby tego artykułu to prosta lista zakupowa. Pozwala nam dodawać / usuwać / edytować poszczególne elementy. Posiada również paginację z możliwością wyboru liczby elementów na jednej stronie i wyszukiwarką. Same dane pobierane i zapisywane są asynchronicznie, w naszym przypadku back-end jest symulowany MSW, jednak mógłby on zostać zaimplementowany bez żadnych zmian w kodzie naszej aplikacji. Jako, że operacje wykonywane są asynchronicznie, z użyciem API, istnieje możliwość wystąpienia błędów, które muszą zostać obsłużone. Dodatkowo potrzebujemy obsługiwać stany ładowania.

Spełnienie tych wszystkich wymagań z użyciem tylko hooków dostarczanych przez Reacta i propsów wymaga sporej ilości kodu. Jak widać na załączonym poniżej snippecie, kod nie jest zbyt czytelny pomimo zastosowania wielu abstrakcji. Ponadto ta implementacja wciąż nie jest gotowa, nie mamy tutaj, chociażby dodanej obsługi błędów, obsługa stanów ładowania jest niepełna, a requesty podczas wpisywania danych do wyszukiwarki nie są przerywane i  powodują wielokrotne rerendery komponentu i nadmiarową ilość zapytań do API. Tyle problemów, a to tylko prosta lista.

Co gdybyśmy potrzebowali pobrać dane z kilku miejsc jednocześnie? W jaki sposób obsłużyć błędy by nie powodować jeszcze większego zamieszania w kodzie? W jaki sposób moglibyśmy zaimplementować Optimistic UI? Co z pobieraniem danych przez komponenty zagnieżdżone?

const [isAddingActive, setIsAddingActive] = useStateWithPersist<boolean>("isAddingActive", false);
const [params, setParams] = React.useState<FilterParams>({ page: 1, perPage: 20, search: "" });
const [isLoading, setIsLoading] = React.useState(true);
const [{ shoppingList, maxPage }, setList] = React.useState<ShoppingListResponse>({ shoppingList: [], maxPage: 0 });

const fetchShoppingList = React.useCallback(() => shoppingListService.get(params).then(setList), [params]);

React.useEffect(() => {
  fetchShoppingList().then(() => setIsLoading(false));
}, [fetchShoppingList]);

const handleAdd = (item: Omit<ShoppingListItem, "id">) =>
  shoppingListService
    .add(item)
    .then(() => setIsAddingActive(false))
    .then(fetchShoppingList);

const handleStatusChange = (newStatus: Pick<ShoppingListItem, "id" | "done">) =>
  shoppingListService.changeDoneStatus(newStatus).then(fetchShoppingList);

const handleDelete = (id: string) => shoppingListService.deleteItem(id).then(fetchShoppingList);

const updateParam = (key: keyof FilterParams) => (value: number | string) =>
  setParams((oldParams) => ({ ...oldParams, [key]: value }));

Co nowego w React Router v6.4.

 

Jak zobaczymy za chwilę, wykorzystując te same komponenty, jesteśmy w stanie rozwiązać wszystkie opisane powyżej problemy, pisząc mniej kodu niż w zaprezentowanym powyżej rozwiązaniu. Wszystko dzięki użyciu react-routera w wersji v6.4 i Reacta 18.

Zacznijmy od krótkiego opisu nowych elementów, które wykorzystamy w naszym projekcie:

  1. Loader i action – jeśli mieliście już styczność z Remixem, to ta koncepcja nie będzie wam obca. W gruncie rzeczy pomysł jest bardzo prosty. Każda ścieżka, definiowana przez komponent Route, może teraz otrzymać propsa loader i action. Loader zostanie wywołany przed zamontowaniem komponentu, a action po przesłaniu formularza Do wartości, które te funkcje zwrócą mamy dostęp za pomocą specjalnych hooków.
  2. Form – Komponent będący wrapperem dla HTMLowego elementu form. Pozwala nam zbierać dane i przesyłać je do funkcji action ustawionej na odpowiedniej ścieżce.
  3. useLoaderData, useActionData – za pomocą tych hooków mamy dostęp do wartości zwracanej kolejno przez funkcje loader i action. W związku z tym, że loader wywoływany jest przed zamontowaniem komponentu, useLoaderData zwraca wartość od pierwszego wywołania, nie musimy czekać, aż dane się pobiorą. useActionData natomiast zwraca undefined tak długo, jak długo nie wykonamy funkcji action (czyli do momentu przesłania formularza).
  4. useNavigation – hook ten pozwala nam sprawdzić aktualny stan naszej aplikacji. Zwraca on informacje, co dzieje się na stronie (idle – wszystkie dane są pobrane, loading – wykonywana jest funkcja loader, submiting – wykonywana jest funkcja action), dodatkowo udostępnia URL aktualnie wykonywanej akcji oraz obiekt typu FormData, z danymi wprowadzonymi do aktualnie przesyłanego formularza (o ile istnieje).

React router v6.4 wprowadza znacznie więcej nowych elementów, pełną listę można znaleźć tutaj.

 

Uzbrojeni w tę wiedzę przyjrzyjmy się teraz implementacji tych funkcjonalności w naszym projekcie.

 

React Router v6.4 na przykładzie

 

W naszym projekcie zastosujemy routing jak na poniższym przykładzie. Choć stworzymy 4 ścieżki: „/”, „/add”, „/changeStatus/:id”, „delete/:id”, to tylko dwie pierwsze z nich będą wyświetlać komponent po aktywacji ścieżki.

Dwie ostatnie odpowiadać będą tylko za mutacje danych (zaznaczono to znakiem x na diagramie). Ścieżki odpowiedzialne za mutacje danych implementują tylko akcję, która zostanie wykonane po jej aktywowaniu, a która na końcu swojego wykonywania zwraca redirect z kolejną ścieżką.

Dzięki takiemu podziałowi jesteśmy w stanie wyeliminować hooka:

const [isAddingActive, setIsAddingActive] = 
useStateWithPersist<boolean>("isAddingActive", false);

W kolejnym kroku dodamy loadery do naszych ścieżek. Sprawdzając jakie zależności mają nasze komponenty, możemy wyeliminować funkcje pobierające dane z komponentów Root i AddShoppingListItem.

Kolejnym etapem naszego refactoringu będą mutacje danych. W tym celu wykorzystamy komponent Form. Tak wygląda gotowy komponent AddShoppingListItem:

<Form method="post">
  <Fieldset aria-errormessage={errorData?.message} disabled={navigation.state !== "idle"}>
    <ListItem
      secondaryAction={
        <IconButton type="submit" edge="end" aria-label="Submit">
          <Done />
        </IconButton>
      }
    >
      <Box width="100%">
        <Box display="flex" justifyContent="center" gap={2} width="100%">
          <TextField
            autoFocus
            variant="standard"
            label="Product"
            name="name"
          />
          <TextField
            variant="standard"
            label="Quantity"
            type="number"
            name="qnt"
          />
          <StyledSelect
            select
            variant="standard"
            label="Unit"
            name="unit"
          >
            {units.map((unit) => (
              <MenuItem key={unit} value={unit}>{unit}</MenuItem>
            ))}
          </StyledSelect>
        </Box>
      </Box>
    </ListItem>
  </Fieldset>
</Form>

Jak widać, nasza zmiana ograniczyła się do zmiany standardowego elementu form, na eksportowany przez react-router komponent Form z atrybutem method=’post’. Kolejnym etapem jest obsłużenie tego formularza, w akcji. Gotowa ścieżka, obsługująca dodawanie nowego elementu listy wygląda zatem następująco:

<Route
  path={routes.ADD}
  loader={shoppingListService.getUnits}
  action={shoppingListService.add}
  element={<AddShoppingListItem />}
/>

Zajmijmy się teraz funkcją shoppingListService.add.

React-router wywoła ją, przekazując między innymi obiekt Request. Obiekt posiada metodę formData, która zwraca obiekt FormData. Dalej postępujemy standardowo, używając Fetch API do komunikacji z naszym back-endem. Na koniec, jeśli po drodze nie napotkaliśmy na żaden błąd, przekierowujemy użytkownika na główną stronę za pomocą eksportowanej przez react-router funkcji redirect.

Ostatnią rzeczą, na którą warto tutaj zwrócić uwagę jest signal, który jest przekazywany przez react-router do naszej akcji. Przekazując go do funkcji fetch uzyskujemy funkcjonalność anulowania requestów przy praktycznie zerowym koszcie! React-router anuluje nasz request np. podczas wymontowania ścieżki albo gdy będzie chciał zrewalidować dane, zanim poprzedni request został wykonany.

add: async ({ request, signal}: DataFunctionArgs) => {
  const formData = await request.formData();
  const item = {
    name: formData.get("name"),
    qnt: formData.get("qnt") ? Number(formData.get("qnt")) : "",
    unit: formData.get("unit"),
    done: false,
  };
  const response = await fetch(API_URL, {
    method: "POST",
    body: JSON.stringify(item),

    signal,
    headers: { "Content-Type": "application/json" },
  });
  if (!response.ok) {
    return { data: item };
  }
  shoppingListItemSchema.parse(await response.json());
  return redirect("/");
},

Nasz refactor zbliża się do końca. Pozostało nam zaimplementować funkcjonalność usuwania i zmiany statusu.
W tym celu stworzymy ścieżki, które nie renderują żadnego komponentu, w zamian obsługując tylko akcje, które po wykonaniu przekierowują na kolejną ścieżkę. Przyjrzyjmy się funkcjonalności usuwania:

<Form method="post" action={`/delete/${item.id}`}>
  <IconButton edge="end" type="submit" aria-label="Delete">
    <Delete />
  </IconButton>
</Form>

Po raz kolejny jedyną zmianą jest zastąpienie elementu form komponentem Form. Tym razem musimy jednak zastosować propa action, który będzie wskazywał na konkretną ścieżkę w obrębie naszej aplikacji, jako że handler naszej akcji znajduje się pod innym linkiem niż sam komponent. W kolejnym kroku dodajemy ścieżkę, która obsłuży naszą mutację:

<Route path={`${routes.DELETE}/:id`} 
action={shoppingListService.deleteItem} />

Jako że ścieżka ta nie ma renderować żadnego komponentu, a tylko obsługiwać mutacje, przekazujemy tylko dwa propsy: path i action. Ostatnim krokiem jest dostosowanie funkcji deleteItem:

deleteItem: async ({ params, request, signal }: DataFunctionArgs) => {
  const urlSearchParams = new URL(request.url).searchParams;

  const response = await fetch(`${API_URL}/${params.id}`, {
    method: "DELETE",
    signal,
    headers: { "Content-Type": "application/json" },
  });
  if (response.ok) {
    return redirect(`/?${urlSearchParams.toString()}`);
  }
  throw new ShoppingListHttpError(`Request failed with status: ${response.status}`);
},

Potrzebny nam parameter ścieżki możemy znaleźć w obiekcie params, pod taką samą nazwą jak w definicji naszej ścieżki, w tym przypadku id. Obsługa usuwania jest gotowa!

Pewnie niektórzy z was zastanawiają się, czy rzeczywiście implementacja ta jest skończona. Ok, usuwamy element z listy, ale co z odświeżeniem widoku? Skąd nasza aplikacja będzie wiedzieć jaki element listy usunąć? Jest to kolejny feature, który został zaimplementowany w react-routerze. 

Wywołanie akcji automatycznie powoduje rewalidacje wszystkich loaderów aktywnych w danej ścieżce. Z tego powodu po dodaniu / usunięciu elementu listy automatycznie wykonywany jest request GET po dane z listy, a użytkownik widzi najświeższe dostępne dane.

To zachowanie jest domyślne, jeśli jednak nie chcemy automatycznie refetchować wszystkich danych po wykonaniu jakiejś akcji, możemy je konfigurować za pomocą propa shouldRevalidate.

Ostatnia funkcjonalność, którą się zajmiemy, to zmiana statusu. W tym celu użyjemy hooka useFetcher. Hook ten pozwala nam wywołać akcję / loader w inny niż domyślny sposób. Oznacza to, że możemy wywołać akcję bez przesyłania formularza. Spójrzmy, jak to wygląda w naszym przypadku:

const [params] = useSearchParams();
const fetcher = useFetcher();

const onStatusChange = (done: boolean) => {
  fetcher.submit(
    { done: done.toString() },
    { method: "post", action: `/changeStatus/${item.id}${params.toString()}` }
  );
};

Wywołanie funkcji fetcher.submit ma dokładnie ten sam skutek, co przesłanie formularza z propami ustawionymi w sposób taki, jak drugi parametr funkcji submit.

Pierwszy parametr odpowiada za dane. Jako że obiekt FormData przechowuje dane w postaci stringów, parametr done musi zostać zrzutowany do stringa. Kolejne kroki są takie same jak w przypadku usuwania, więc zostaną pominięte.

Uff udało się!

Ilość zmian była dość duża, jednak moim zdaniem jakość kodu jest znacznie wyższa. Całą listę wprowadzonych zmian możecie przejrzeć tutaj. A jeśli chodzi o same liczby, to również są one bardzo zachęcające. Spójrzcie tylko na obrazek poniżej:

Poprawiliśmy czytelność kodu i wydajność naszej aplikacji, de facto usuwając 150 linii kodu. Ponadto jesteśmy w stanie w kilku prostych krokach zaimplementować obsługę błędów oraz optimistic UI. Ale to już materiał na kolejny post, który pojawi się… już niedługo!

 

Performance

 

Udało nam się już polepszyć nasz projekt w kilku aspektach. Kolejny benefit, który dostaliśmy niejako „za darmo”, to performance. Dzięki temu, że react-router wewnętrznie używa Suspense’u, jest w stanie zoptymalizować proces ładowania danych w naszej aplikacji. Na początek zobaczmy, jak wygląda ładowanie danych w wersji bez routingu:

Widzimy tutaj zalążek bardzo niepożądanego kształtu, mianowicie wodospadu. Niestety, przez to, że w standardowych aplikacjach za pobieranie danych odpowiedzialny jest sam komponent, który ich używa, taki kształt jest bardzo częsty. Schemat jest następujący:

Komponent 1 pobiera dane => Renderuje się Komponent 2 => Komponent 2 pobiera dane itd.

Dzięki temu, że react-router wie, jakie dane potrzebne są dla konkretnej ścieżki (łącznie z zagnieżdżonymi ścieżkami), jest w stanie wysłać wszystkie wymagane requesty „za jednym zamachem”. Dzięki temu wykres naszych requestów wygląda tak jak poniżej:

Nasza aplikacja jest bardzo prostą listą, a mimo to różnica jest zauważalna. Łatwo można estymować, że wraz ze wzrostem aplikacji i ilości funkcjonalności na danej ścieżce korzyść, którą odnosimy dzięki zastosowaniu react-routera wzrasta. Każdy użytkownik naszej aplikacji z pewnością doceni drastyczne skrócenie czasów ładowania!

 

Podsumowanie

 

We wstępie tego artykułu obiecywałem zastąpienie menedżerów stanu za pomocą nowej wersji react-routera. Chciałbym zwrócić uwagę na jedną kwestię, którą być może pominęliście (a być może nie!). W końcowej wersji naszej aplikacji nie ma ani jednego użycia React.useState oraz React.useEffect! Skoro nie używamy bezpośrednio ani hooków do zarządzania stanem ani zewnętrznych bibliotek, okazuje się, że nie potrzebujemy również żadnego dodatkowego rozwiązania do zarządzania stanem! Całość tego zadania przejął nowy react-router oraz pasek adresu. Dzięki odpowiedniej kompozycji komponentów i zagnieżdżonym ścieżkom uniknęliśmy również props drilling.

Dzięki takiemu podejściu jesteśmy w stanie wykorzystać nowy mechanizm Reacta (Concurrent Mode). Polepszamy UX, zmniejszając czasy ładowania, jak i DX, dzięki bardziej przejrzystemu kodowi. Oczywiście zaprezentowany projekt wciąż można rozwijać, np. dodając react-query w celu cache’owania danych po stronie klienta. Dzięki zmianie architektury możemy również w bezbolesny sposób zacząć używać Remixa.

Co sądzicie o nowej wersji React-Routera? Czy planujecie ją zaimplementować w swoich projektach? A może macie jakieś obawy lub widzicie zagrożenia wynikające z nowego API?

 

A już wkrótce na naszym blogu znajdziecie post, w którym do naszego projektu dodamy obsługę błędów i optimistic UI!

Najnowsze wpisy