React Query – jak zarządzać stanem serwerowym

Spis treści

  1. Wprowadzenie
  2. Fejkowe API dzięki ServiceWorker
  3. Zastosowanie React Query
    1. React Query a lista postów
      1. useQuery w liście postów
      2. useMutation w liście postów
    2. Lista komentarzy w poście
      1. useQuery w liście komentarzy
      2. useMutation w liście postów
    3. Pełen przykład React Query na Github
    4. Klikalny przykład React Query na Github Pages
  4. Podsumowanie

Wprowadzenie

Dlaczego warto poznać React Query?

Do tej pory w projektach które tworzyłem do zarządzania stanem używany był głównie Redux. Lądowały w nim dane z serwera, stan urządzeń jak np wybrana przez użytkownika kamera i mikrofon czy różne dane potrzebne w wielu miejscach aplikacji jak uprawnienia użytkownika w systemie.

Aplikacje te miały dość rozbudowany stan kliencki, do przechowywania którego Redux jest stworzony. Stan “serwera”, czyli dane naszej aplikacji jest inny w swoim zachowaniu. Zmienia się bez wiedzy frontendu, może być z jakiegoś powodu niedostępny, łatwo też stracić synchronizację z danymi z serwera, które mogą się zmieniać w trakcie pracy klienta.

Stąd, jeśli stan aplikacji to głównie stan serwera a nie stan kliencki, rozsądne wydaje się zastosowanie biblioteki React Query. Zatroszczy się ona o cache danych, przechowywanie w aplikacji i aktualizację na serwerze.

Fejkowe API dzięki ServiceWorker

By pokazać niektóre funkcjonalności React Query w całej okazałości, będziemy potrzebować wprowadzić API które będzie przetrzymywać zmiany przez nas wprowadzone – przynajmniej w zakresie sesji przeglądarki.

Możnaby użyć jakiegoś prostego serwera, jednak można również użyć ServiceWorkera który będzie reagował na wysłane przez Fetch API requesty.

Takie rozwiązanie ma jednak jedną wadę – musimy odświeżyć stronę w sytuacji, gdy worker się nie zamontuje, co może mieć miejsce np podczas odświeżenia strony z CTRL + F5:

if (!navigator.serviceWorker.controller) {
  return window.location.reload();
}

Nie jest to jednak problem podczas lokalnego developmentu gdzie przeładowanie będzie szybkie.

Kod nasłuchiwania wyglądać będzie następująco:

self.addEventListener('fetch', event => {
  if (event.request.url.includes('apis')) {
    const response = getResponse(event.request);
    event.respondWith(new Promise(resolve => {
      setTimeout(() => resolve(response), 3000);
    }));
  }
});

Za każdym razem gdy aplikacja wyśle żądanie przez fetch, jeśli adres zapytania będzie zawierał ‘apis’ odpowiedź zostanie stworzona w funkcji getResponse i przekazana jako zwrotka do aplikacji:

let posts = [];

const getResponse = (request) => {
  if (request.url.endsWith('apis')) {
    return new Response(JSON.stringify({ posts }));
  }
  if (request.url.endsWith('apis/add')) {
    request.json().then(post => posts = [
      ...posts,
      {
        ...post,
        id: new Date().getTime(),
      },
    ])
    return new Response('');
  }
}

Samo wysyłanie zapytań wyglądać będzie następująco:

await fetch('apis');

await fetch('apis/add', {
  method: 'POST',
  body: JSON.stringify({author: 'Radek 2', title: 'Some title 2', message: 'Some message 2'}),
});

Dodatkowo serviceWorker zawierać będzie również obsługę usuwania postów oraz komentarze – pełny kod dostępny będzie na Githubie.

Jeśli chcemy mieć obsługę servicerWorker w aplikacji CRA, najprostszą opcją jest wybór odpowiedniego szablonu:

 npx create-react-app react-query-demo --template cra-template-pwa

Zastosowanie React Query

React Query a lista postów

useQuery w liście postów

Aby rozpocząć pracę z React Query musimy podpiąć odpowiedni provider:

const client = new QueryClient()

return (
  <QueryClientProvider client={client}>
    <App />
  </QueryClientProvider>
);

Mając już bazę do dalszych prac w głównym komponencie aplikacji wykorzystamy hook useQuery do wczytania listy postów:

const defaultOptions = { refetchOnWindowFocus: false };

const { isFetching, data } = useQuery('posts', async () => {
  const response = await fetch('apis');
  const { posts } = await response.json();
  return posts;
}, defaultOptions);

Dzięki temu że hook zwraca informację o tym czy zapytanie trwa możemy wyświetlić informację o ładowaniu:

<>
  <h3>Posts:</h3>
  {isFetching && 'Loading posts...'}
  {data?.map(post => <Post key={post.id} post={post} />)}
  <AddPost />
</>

Flaga isFetching od flagi isLoading różni się tym, że ta druga jest ustawiana na true tylko podczas pierwszego załadowania danych.

Ważną funkcjonalnością biblioteki jest to, że podczas ładowania nowych danych zwracane są dane poprzednie. Dlatego też potrzebowaliśmy API przetrzymującego stan 🙂
Podczas dodawania postu, mimo iż wyświetla się informacja o ładowaniu danych, poprzednie posty po dodaniu nowego są cały czas wyświetlane:

useMutation w liście postów

Aby wymusić na bibliotece odświeżenie danych, zdeklarujemy to w mutacji dodającej nowego posta:

const queryClient = useQueryClient();

const { mutate, isSuccess, reset } = useMutation(comment => (
  fetch(`apis/add`, {
    method: 'POST',
    body: JSON.stringify(post),
  })
), {
  onSuccess: () => {
    queryClient.invalidateQueries('posts', {exact: true});
  }
});

Funkcja invalidateQueries jako parametr przyjmuje nazwę query które chcemy zinwalidować oraz opcje. Potrzebujemy użyć flagi exact, nie zostały zinwalidowane inne zapytania które zaczynają się od słowa ‘posts’.

Do samego wysłania requestu na backend posłuży nam funkcja onSubmit. Funkcja ta użyje mutate zwróconego przez hook useMutation:

const onSubmit = e => {
  e.preventDefault();
  mutate(post);
};

Dzięki funkcji reset również zwróconej z hooka useMutation możemy pozbyć się informacji mówiącej o sukcesie wysłania requestu na serwer:

const createOnnChange = key => e => {
  if (isSuccess) {
    reset();
  }
  setPost(post => ({...post, [key]: e.target.value}));
};

Dzięki temu flaga isSuccess zostanie przywrócona do początkowego stanu i zniknie informacja na widoku. Co ważne, reset nie spowoduje przeładowania listy postów.

Sam kod formularza wygląda następująco:

return (
  <form onSubmit={onSubmit}>
    <input placeholder="Author" value={post.author} onChange={createOnChange('author')} />
    <input placeholder="Title" value={post.title} onChange={createOnChange('title')} />
    <input placeholder="Message" value={post.message} onChange={createOnChange('message')} />
    <button type="submit">Add comment</button>
    <br />
    {isSuccess && 'post Successfully sent'}
  </form>
);

Lista komentarzy w poście

useQuery w liście komentarzy

Ponieważ komentarze chcemy ładować dla konkretnych postów, musimy id tego posta przekazać jako zależność do hooka useQuery:

const { isFetching, data } = useQuery(['comments', postId], async () => {
  const response = await fetch(`apis/comments/${postId}`);
  const { comments } = await response.json();
  return comments;
}, defaultOptions);

Hook useQuery przyjmuje nazwę identyfikującą dany hook bądź listę elementów, która pozwoli go zidentyfikować. W naszym przypadku id posta jest unikalne, więc wystarczy ono aby biblioteka rozróżniła komentarze dla poszczególnych postów.

Analogicznie jak w przykładzie dla postów, nad listą komentarzy będziemy wyświetlać informację o ładowaniu danych:

<>
  <h5>Comments</h5>
    {isFetching && 'Loading comments...'}
    {data?.map(comment => (
      <div key={comment.id} style={{border: '3px solid green', margin: '4px', padding: '4px'}}>
        <h6>{comment.author}</h6>
        <div>{comment.message}</div>
      </div>
    )
  )}
  <AddComment postId={postId} />
</>

Dzięki przyciskowi Show comments możesz doładować komentarze dla danego posta, dla widocznych komentarzy pokaże się przycisk Hide comments. Pozwoli to potwierdzić, że biblioteka przetrzymuje poprzednie dane pomiedzy rerenderami – klikając przycisk zauważysz w konsoli że zapytanie jest wysyłane, jednak po pierwszym załadowaniu dane są uaktualniane i wyświetlone po tym, jak zapytanie się skończy.

useMutation w liście postów

Użycie wygląda analogicznie do poprzedniego:

const { mutate, isSuccess, reset } = useMutation(['comments', postId], comment => (
  fetch(`apis/comments/add/${postId}`, {
    method: 'POST',
    body: JSON.stringify(comment),
  })
  ), {
    onSuccess: () => {
      queryClient.invalidateQueries(['comments', postId], {exact: true});
    }
});

Ważne by pamiętać o zinwalidowaniu listy komentarzy dla danego posta oraz o tym, by do useMutation również przekazać id postu, by np reset resetował tylko to zapytanie.

Reset również odbywa się przy rozpoczęciu wpisywania danych nowego komentarza:

const createOnChange = key => e => {
  if (isSuccess) {
    reset();
  }
  setComment(comment => ({...comment, [key]: e.target.value}));
}

A sam formularz wygląda następująco:

<form onSubmit={e => {
  e.preventDefault();
  mutate(comment);
}}>
  <input placeholder="Author" value={comment.author} onChange={createOnChange('author')} />
  <input placeholder="Message" value={comment.message} onChange={createOnChange('message')} />
  <button type="submit">Add comment</button>
  <br />
  {isSuccess && 'comment Successfully sent'}
</form>

Pełen przykład React Query na Github

Pełen kod przykładu znajdziesz na Github pod adresem:

https://github.com/radek-anuszewski/react-query-demo

Klikalny przykład React Query na Github Pages

Klikalny przykład znajdziesz pod adresem:

https://radek-anuszewski.github.io/react-query-demo/

Podsumowanie

Biblioteka React Query umożliwia bardzo sprawne zarządzanie stanem serwerowym w aplikacji, dzięki między innymi inwalidowaniu zapytań i wyświetlaniu poprzednich danych zanim załadują się nowe, co poprawia UX.
Warto więc ją poznać i stosować, gdy samego stanu czysto klienckiego w aplikacji mamy mało.