RecoilJS - stan oparty o asynchroniczne zdarzenia

W poprzednim artykule zapoznaliśmy się z podstawowym API jakie oferuje biblioteka Recoil. Dzisiaj przejdziemy do bardziej zaawansowanej kwestii - pokażę Wam jak obsługiwać stan oparty o asynchroniczne zdarzenia. Posłużę się niczym innym, jak opisanymi w poprzednim artykule atomami oraz selektorami.

A zbudujemy dziś…

prosty interfejs składający się z listy użytkowników oraz szczegółów wybranego rekordu. Przykład nie jest skomplikowany, gdyż kwestie, które poruszę będą już wystarczająco wymagające. Nauczymy się dziś obsługi asynchronicznych zdarzeń z użyciem Recoila w połączeniu z React.Suspense. O Recoilu już trochę wiecie, jeżeli czytaliście poprzedni artykuł, Suspense natomiast jest obecnie gorącym tematem (na dzień dzisiejszy jeszcze w fazie eksperymentalnej) w świecie Reacta. Dokładając do tego fakt, że Suspense jest polecany przez zespół tworzący Recoila - oznacza to, że nie możemy nie spróbować się nim pobawić.

A oto jak będzie wyglądał nasz dzisiejszy twór:

recoil-user

P.S. bystrzaki pewnie zauważyły, że na powyższym gifie powtórne zapytanie o tego samego użytkownika wraca od razu. Jest to jedno z udogodnień zapewnianych nam przez Recoil - cache’owanie danych pochodzących ze źródeł służących tylko do odczytu. W naszym przykładzie nie obsługujemy synchronizowania stanu klienta z serwerem, więc nasze dane właśnie takie są. Oznacza to tyle, że za drugim i każdym kolejnym razem, zamiast wykonywać ponowne zapytanie, wracają zapisane w pamięci dane.

Odrobina teorii przed praktyką

Do obsługi asynchroniczności wykorzystamy znane Wam już z poprzedniego artykułu selektory. Uzupełniając nieco wiedzę z poprzedniego artykułu warto powiedzieć, że funkcje podawane do pola “get” selektorów mogą być asynchroniczne. Oznacza to, że możemy je udekorować słowem kluczowym async i korzystać wewnątrz nich z awaita (więcej do poczytania tutaj), lub zrobić dokładnie to samo z użyciem Promise’ów. Działa to podobnie jak w synchronicznym przykładzie z licznikiem / kalkulatorem. W momencie zwrócenia wartości wszystkie powiązane selektory zostaną zaktualizowane, a widoki przerenderowane. Brzmi świetnie, co?

Warto powiedzieć jeszcze przynajmniej dwa słowa o Suspense. Wolałbym się nie rozwodzić zbyt długo na ten temat, więc będzie w dużym skrócie. W tej chwili ważne jest, żebyście zapamiętali, że Suspense jest mechanizmem pozwalającym na wstrzymanie renderowania widoku uzależnionego od asynchronicznych danych aż do momentu ich otrzymania. Jak dokładniej to działa, przekonacie się zaraz.

Więcej do poczytania o Suspense zostawię na koniec posta w sekcji linków.

Symulowanie asynchroniczności

Aby uniknąć stawiania backendu zasymulujemy asynchroniczną odpowiedź przychodzącą z serwera. Jak to zrobimy? Skorzystamy z Promise’a zawierającego w sobie setTimeout. Dokładniej mówiąc - po upłynięciu określonego czasu wywołanie funkcji zwrotnej podanej do setTimeout spowoduje wypełnienie się Promise’a. Będzie to scenariusz podobny jak przy klasycznej komunikacji klient-serwer, gdzie wysyłając zapytanie, oczekujemy na odpowiedź. Powyższy mechanizm zwróci zdefiniowane przez nas samych dane, którymi posłużymy się w przykładzie.

Implementacja poniżej:

//sztuczne dane - lista użytkowników oraz lista pełnych danych
const MOCK_USERS = [
 { id: 1, username: "testuser123" },];

const USERS_WITH_DETAILS = [
 {
   id: 1,
   username: "testuser123",
   name: "Test",
   surname: "User",
   age: 20,
   hobby: "Programming",
 },];
// funkcje zwracające nasze przygotowane dane
export const queryUsers = () => {
 return new Promise((resolve) => {
   setTimeout(() => {
     resolve(MOCK_USERS);
   }, 3000);
 });
};

export const queryUserDetails = (id) => {
 return new Promise((resolve) => {
   const user = USERS_WITH_DETAILS.find((user) => user.id === parseInt(id));

   if (!user) resolve(null);

   setTimeout(() => {
     resolve(user);
   }, 3000);
 });
};

Definicja stanu Recoila

Będziemy potrzebowali trzech pól przechowywanych w stanie:

  • Lista wszystkich użytkowników, którą później wyświetlimy w postaci pola select wraz z opcjami. Zauważ, że stan oparty jest o zdefiniowaną wcześniej funkcję asynchroniczną pobierającą użytkowników.
export const usersListState = selector({
  key: "usersListState",
  get: async () => await queryUsers(),
});
  • ID wybranego użytkownika (niezbędne, aby pobrać jego pełne dane). Inicjalnie będzie puste - zauważ, że określiłem je jako “null” (ciąg znaków), żeby uniknąć błędów Reacta mówiących, że select / option nie powinno przyjmować wartości null. Jest to jedyny fragment stanu, zmieniający się w wyniku operacji wykonanych przez użytkownika. Jest on więc lokalnym stanem aplikacji, dlatego też został opisany przy użyciu atomu.
export const selectedUserIdState = atom({
  key: "selectedUserIdState",
  default: "null",
});
  • Szczegółowe dane wybranego użytkownika, o które odpytamy się w momencie wybrania użytkownika z listy. Zauważ, że funkcja pobiera ID wybranego użytkownika z atomu, a następnie z jego użyciem asynchronicznie pobiera szczegóły danego rekordu.
export const selectedUserDetailsState = selector({
  key: "selectedUserDetailsState",
  get: async ({ get }) => {
    const userId = get(selectedUserIdState);

    return await queryUserDetails(userId);
  },
});

Komponenty wykorzystujące stan

Stworzymy trzy komponenty:

  • Wybieralna lista użytkowników – Zwróć uwagę, że sposób użycia API Recoila wewnątrz komponentów (useRecoilValue i useRecoilState) nie różni się niczym od przykładu z synchronicznym Counterem i Kalkulatorem.
export const UserPicker = () => {
  const userList = useRecoilValue(usersListState);
  const [selectedUserId, setSelectedUserId] = useRecoilState(
    selectedUserIdState
  );

  const onChange = (e) => {
    setSelectedUserId(e.target.value);
  };

  const renderList = () => {
    return userList.map((user) => (
      <Styled.Option value={user.id} key={user.id}>
        {user.username}
      </Styled.Option>
    ));
  };

  return (
    <Styled.Select onChange={onChange} value={selectedUserId}>
      <Styled.Option value={"null"} disabled>
        Wybierz użytkownika...
      </Styled.Option>
      {renderList()}
    </Styled.Select>
  );
};
  • Szczegóły wybranego użytkownika
export const UserDetails = () => {
  const userDetails = useRecoilValue(selectedUserDetailsState);

  return userDetails ? (
    <Styled.Form>
      <Styled.Row>
        <Styled.Label>ID: </Styled.Label>
        <Styled.Value>{userDetails.id}</Styled.Value>
      </Styled.Row>
      <Styled.Row>
        <Styled.Label>Username: </Styled.Label>
        <Styled.Value>{userDetails.username}</Styled.Value>
      </Styled.Row>
      <Styled.Row>
        <Styled.Label>Imię: </Styled.Label>
        <Styled.Value>{userDetails.name}</Styled.Value>
      </Styled.Row>
      <Styled.Row>
        <Styled.Label>Nazwisko: </Styled.Label>
        <Styled.Value>{userDetails.surname}</Styled.Value>
      </Styled.Row>
      <Styled.Row>
        <Styled.Label>Wiek: </Styled.Label>
        <Styled.Value>{userDetails.age}</Styled.Value>
      </Styled.Row>
      <Styled.Row>
        <Styled.Label>Hobby: </Styled.Label>
        <Styled.Value>{userDetails.hobby}</Styled.Value>
      </Styled.Row>
    </Styled.Form>
  ) : null;
};
  • Kontener opakowujący oba powyższe, w którym wykorzystamy Suspense do pokazywania widoku wczytywania w momencie, gdy będziemy oczekiwać na odpowiedź.
export const Users = () => {
 return (
   <Styled.Container>
     <React.Suspense
       fallback={<Styled.Loading>Wczytywanie użytkowników...</Styled.Loading>}
     >
       <UserPicker />
     </React.Suspense>
     <React.Suspense
       fallback={<Styled.Loading>Wczytywanie szczegółów...</Styled.Loading>}
     >
       <UserDetails />
     </React.Suspense>
   </Styled.Container>
 );
};

I to by było na tyle!

Udało nam się poskromić asynchroniczność używając Recoila. Biblioteka oferuje również synchronizację stanu lokalnego ze stanem zdalnym. Niestety w dniu pisania tego artykułu stanowi ono niestabilne API. Oznacza to nic innego jak wysokie prawdopodobieństwo, że ulegnie ono zmianie. Jeżeli tylko nabierze nieco stabilności i będę miał możliwość zmierzenia się z nim, to na pewno szczegółowo opiszę Wam wynik tych zmagań ;) Dla chętnych - tutaj znajdziecie nieco więcej informacji na ten temat.

Opisany przykład został dołączony do repozytorium z poprzedniego artykułu. Link do repo znajdziecie tutaj.

Więcej do poczytania o obsłudze asynchroniczności w Recoil:

I o Suspense:

Do góry