Infinite Scrolling w React (CRA, TS, Strapi, Material UI, GraphQL, Apollo)

Ekosystem Node i React jest ogromny. Na rynku do dyspozycji programistów dostępna jest masa gotowych już rozwiązań i bibliotek, które śmiało można wykorzystywać w projektach. Dziś użyjemy je, by stawić czoła dość popularnemu przypadkowi kodowemu na froncie, jakim jest infinite scrolling.

Wprowadzenie

Ostatnio poszerzam swoje umiejętności poprzez naukę nowych, jak dotąd nie używanych przeze mnie bibliotek i technologii, z których być może skorzystam w przyszłości. Podczas nauki wpadłem na pomysł, by przy okazji przedstawić jakiś code case z wykorzystaniem tego, czego się aktualnie uczę i wybór padł na infinite scrolling z wykorzystaniem danych i api. Ten wpis to pewnie jakiegoś rodzaju tutorial, który możesz wykonywać wraz ze mną lub po prostu wczytać się w kod i czytać, co w kontekście powyższego przypadku poczyniłem. Sam projekt (projekcik) nie będzie rozbudowany, więc postaram się wszystko przekazać klarownie. Miłej lektury!

Czego potrzebujemy?

Do zaprezentowania nieskończonego scrollowania potrzebne będą jakieś dane. Oczywiście mogłyby być to zmockowane wartości, ale bez sensu implementować typ paginacji, jakim jest infinite scrolling na froncie. Potrzebujemy więc api. Tutaj strzał padł na Strapi, który sam sobie lokalnie postawię i zaimportuję do niego dane w postaci komentarzy (na ten moment jest to wystarczające - poszczególne technologie będę przedstawiał bardziej szczegółowo w konkretnych etapach developmentu). Do komunikacji z api skorzystam z GraphQL na (można tak powiedzieć) nieodłącznym do tego języka frameworku ApolloClient. Jako UI framework wykorzystam Material UI dla Reacta, a całość będzie opakowana w CRA z TypeScriptem. Wykorzystana wersja node to 14.18.1, a npm 6.14.15.

Linki do poszczególnych rozwiązań:

Na ten moment to wszystko, czego potrzebujemy. Teraz możemy zagłębić się w kod, ale zanim to...

Setup backendu z wykorzystaniem Strapi

Jak już wspomniałem, potrzebujemy jakieś api, żeby dane można było paginować. Mogłbym tutaj wykorzystać otwarte api z gotową dla niego paginacją, ale czemu by nie skorzystać z własnego (lokalnego) serwera? Oczywiście nie będziemy tutaj implementować jakichś rozbudowanych endpointów, ale skorzystamy z narzędzia, które to zrobi za nas. Będzie nim Strapi. Strapi to silnik headless CMS napisany w node, którego postawienie opiera się na kilku komendach z menadżera pakietów. Domyślnie zapewnia on komunikację RESTową, ale jednym pluginem ogarniemy silnik wykorzystujący GrqphQL (a przypominam, że to ten język wykorzystam do łączenia z api). Dodam tylko, że jeśli wykorzystujemy to narzędzie samodzielnie je hostując, to jest ono w pełni darmowe. Do dzieła więc.

Zgodnie z dokumentacją, aby postawić projekt, muszę wykonać następującą komendę:

npx create-strapi-app is-api

Nazwałem ją skrótem is od infinite scrolling. Po odpaleniu polecenia zostaniemy poproszeni o wybór typu instalacji aplikacji. Wybieramy quickstart (spowoduje to, że skorzystamy z bazy danych SQLite, a nie np. Mongo, które wymaga instalacji paczek standalone w systemie), po czym podajemy, że nie chcemy korzystać z żadnego szablonu (to z kolei zapewni czysty projekt, bez żadnych predefiniowanych kolekcji). Po poprawnej isnstalacji projekt się uruchomi na localhoście po raz pierwszy, a my musimy wtedy zarejestrować super admina do panelu CMS.

zdjęcie tematyczne
Ekran rejestracji po pierwszym uruchomieniu - na dobrą sprawę możemy wpisać cokolwiek. Dane te będą dostępne tylko w projekcie

Jeżeli projekt się nie uruchomił (lub za każdym razem kiedy, będziesz chciał uruchomić projekt), wpisz:

npm run develop

Po rejestracji otrzymujemy dostęp do panelu CMS, w którym możemy tworzyć kolekcje i schema bazy danych. Do tego przejdziemy za chwilę. Na ten moment musimy zrobić jeszcze jedną rzecz. Strapi domyślnie wspiera REST api, a nie GraphQL, które chcę użyć. Aby to rozwiązać, użyję oficjalny plugin dla Strapi, który dodaje silnik GraphQL, wpiszę więc w terminalu:

npm run strapi install graphql

Ponadto, będę potrzebował jeszcze jeden plugin, który pozwala na import/export danych do Strapi (ten jest akurat nieoficjalny), wpisuję więc:

npm i strapi-plugin-import-export-content && npm run build -- --clean

Druga komenda odpowiada za przebudowanie panelu admina. Da nam to pewność, że po kolejnym developie wszystko będzie działać jak należy. Teraz możemy przejść do stworzenia modelu kolekcji. Wspomniałem wcześniej, że nasze dane będą w postaci komentarzy. Je wezmę sobie dane od JSONPlaceholder. Jest to payload 500 komentarzy, które będziemy wyświetlać i ładować po stronie frontu. Na ten moment musimy je sobie zaimportować w Strapim. Aby to zrobi, najpierw stworzymy model komentarza. Przykładowy komentarz w json wygląda tak:

{
    "postId": 1,
    "id": 4,
    "name": "alias odio sit",
    "email": "Lew@alysha.tv",
    "body": "non et atque\noccaecati deserunt quas accusantium unde odit nobis qui voluptatem\nquia voluptas consequuntur itaque dolor\net qui rerum deleniti ut occaecati"
  }

Są tutaj 3 istotne pola: name, email oraz body. postId możemy sobie odpuścić. Do identyfikacji komentarza wystarczy nam samo id. Stwórzmy więc na jego podstawie model w Strapim:

zdjęcie tematyczne
Content-Types Builder -> Create new collection type
zdjęcie tematyczne
Wszystkie pola to typy tekstowe (body - Rich Text)

Teraz możemy zaimportować dane do nowo utworzonej kolekcji

zdjęcie tematyczne
Import / Export content -> Import Data

I voilà. Mamy już dane w bazie dostępne do fetchowania. Aby upewnić się, czy dane są na pewno widoczne, wejdziemy do graphiql i zajrzymy, czy wszystko gra.

zdjęcie tematyczne

Niestety, ale to nie wszystko, co musimy zrobić. Domyślnie Strapi udostępnia kolekcje tylko dla autoryzowanych użytkowników. Stan ten możemy zmienić w ustawieniach projektu.

zdjęcie tematyczne
Settings -> USERS & PERMISSIONS PLUGIN -> Roles -> Public

Nadajemy uprawnienia do grupy Public w postaci count, find, findOne. To pozwoli czytać dane na froncie bez autoryzacji.

zdjęcie tematyczne
Po zmianie uprawnień dla roli, dostajemy upragnione dane.

To wszystko! Mamy postawiony backend i poprawnie zaimportowane dane w bazie. Nic już teraz nie stoi na przeszkodzie, żeby wziąć się za pokazanie tych danych w Reakcie.

Setup frontendu

Do frontendu wykorzystam standardową, bardzo dobrze znaną komendę:

npx create-react-app is-fe --template typescript --use-npm

I od razu zainstalujemy wymagane paczki:

npm install @mui/material @emotion/react @emotion/styled @apollo/client graphql react-infinite-scroll-component

To wszystko, co nam potrzeba. Dodam, że dodałem jeszcze sobie eslint wraz z następującym configiem (większość z tych zasad zdefiniowałem za pomocą komendy npx eslint --init):

// .eslintrc.json
{
  "env": {
    "browser": true,
    "es2021": true
  },
  "extends": ["plugin:react/recommended", "airbnb"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 12,
    "sourceType": "module",
    "project": "./tsconfig.json"
  },
  "plugins": ["react", "@typescript-eslint"],
  "rules": {
    "react/jsx-filename-extension": [
      1,
      { "extensions": [".js", ".jsx", ".ts", ".tsx"] }
    ],
    "no-console": 0,
    "no-use-before-define": "off",
    "@typescript-eslint/no-use-before-define": ["error"],
    "no-unused-vars": "warn",
    "import/extensions": [
      "error",
      "ignorePackages",
      {
        "js": "never",
        "jsx": "never",
        "ts": "never",
        "tsx": "never"
      }
    ],
    "object-curly-newline": 0,
    "import/prefer-default-export": 0,
    "react/jsx-props-no-spreading": 0,
    "react/jsx-one-expression-per-line": 0,
    "no-shadow": 0
  },
  "settings": {
    "import/resolver": {
      "node": {
        "extensions": [".js", ".jsx", ".ts", ".tsx"]
      }
    }
  }
}

Apollo Client

Do komunikacji api wykorzystamy GraphQL i libkę Apollo Client. Zainicjujmy nasz główny obiekt w podfolderze apollo:

// api/constants.ts
export const BASE_URL = 'http://localhost:1337/graphql';
// api/index.ts
import { ApolloClient, InMemoryCache } from '@apollo/client'; // InMemoryCache zapewnia cache do wybranych query
import { BASE_URL } from './constants'; // importujemy stały URI

export const client = new ApolloClient({
  uri: BASE_URL,
  cache: new InMemoryCache(),
});

W ten sposób stworzymy nasz główny obiekt client, który zaimportujemy w głównym pliku:

// index.tsx
import { gql } from '@apollo/client';
import React from 'react';
import ReactDOM from 'react-dom';
import { client } from './api';
import App from './App';

client        // wykonujemy testowe query, by sprawdzić, czy wszystko działa jak należy
  .query({
    query: gql`
      query {
        comments {
          id
        }
      }
    `,
  })
  .then((result) => console.log(result));

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root'),
);

zdjęcie tematyczne
Po wylogowaniu nasze dane są widoczne

Apollo Client współdziała z contextem. Bilblioteka ta ma więc swój Provider, który zastosujemy w ten sposób:

// index.tsx
ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById('root'),
);

Będzie nam on potrzebny do hooków. Mamy już pewność, że komunikacja z naszym api będzie działać. Naszym celem jest jednak nie query na wszystkie komentarze naraz, a nieskończona paginacja. Zdefiniuję więc pierwsze query:

// api/queries.ts
import { gql } from '@apollo/client';

export const FETCH_COMMENTS_WITH_PAGINATION = gql`
  query ($limit: Int!, $offset: Int!) {.   // zmienne limit oraz offset będą decydowały jak dużo danych zaciągniemy
    comments(limit: $limit, start: $offset) {
      id
      name
      email
      body
    }
  }
`;

Będziemy się do niego odwoływać za każdym razem, kiedy wykonamy rerequest. Stwórzmy przy okazji jeszcze jedno query:

export const COUNT_COMMENTS = gql`
  query {
    commentsConnection {
      aggregate {
        count
      }
    }
  }
`;

Pozwoli to na zliczenie wszystkich komentarzy.

Zabawa ze scrollem

Stylowania nie będzie za dużo. Ba. Nie będzie go wcale. Wcześniej powiedziałem, że użyję do tego Material UI. Szczerze mówiąc, nie jestem fanem UI frameworków, bo strasznie mnie ograniczają, ale na taki case jak znalazł. Sam JSX nie będzie zbyt duży.

Długo zastanawiałem się też, czy napisać własne rozwiązanie, czy może skorzystać z gotowego. Wybór padł na tą druga opcję. Napisanie takiego mechanizmu od zera byłoby zbyt czasochłonne. Podobnie jak w innych przypadkach posłużymy się gotową biblioteką (react-infinite-scroll-component):

// components/Comments.tsx
import React from 'react';
import { Container } from '@mui/material';
import InfiniteScroll from 'react-infinite-scroll-component';
import Comment from './Comment';
import Loader from './Loader';
import useComments from '../hooks/useComments';

const Comments = () => {
  // całą logikę z API zawrzemy w osobnym hooku useComments
  const { count, fetchComments, comments } = useComments();

  return (
    <Container
      maxWidth="lg"
      style={{ marginTop: '150px', marginBottom: '150px' }}
    >
      <h1>Total comments: {count}</h1>{' '}
      {/* Wyświetlimy całkowitą ilość komantarzy z backendu */}
      <InfiniteScroll
        // Przekażemy aktualną liczbe zaciągniętych komentarzy
        dataLength={comments.length}
        // Wyślemy funkcję do refetcha - za każdym razem, kiedy zescrollujemy do dołu
        next={fetchComments}
        loader={<Loader />}
        // Sprawdzimy, czy zaciągnięte komentarze przekraczają całkowitą ich liczbę
        hasMore={comments.length < count}
        endMessage={<h3>That&apos;s all :)</h3>}
        style={{ overflow: 'visible' }}
      >
        {/* Wyświetlimy lokalnie zaciągnięte komentarze */}
        {comments.map(({ id, ...comment }) => (
          <Comment key={id} {...comment} />
        ))}
      </InfiniteScroll>
    </Container>
  );
};

export default Comments;

Jest to nasz container dla komentarzy. Cała magia będzie odbywać się we własnym hooku useComments. W tym przypadku odbierać będziemy jedynie zaciągnięte komentarze z api, ich całkowitą liczbę (również z api) oraz funkcję do requestów o kolejne komentarze. Spójrzmy, jak będzie się prezentował hook:

// hooks/useComments.ts
import { useEffect, useState } from 'react';
import { useQuery, useLazyQuery } from '@apollo/client';
import { COUNT_COMMENTS, FETCH_COMMENTS_WITH_PAGINATION } from '../api/queries';
import { CommentType } from '../types';

const COMMENTS_FETCH_LIMIT = 15;

interface CommentsData {
  comments: CommentType[];
}

interface CountCommentsData {
  commentsConnection: {
    aggregate: {
      count: number;
    };
  };
}

const useComments = () => {
  const [comments, setComments] = useState<CommentType[]>([]);

  const { data } = useQuery<CountCommentsData>(COUNT_COMMENTS);

  const [getComments] = useLazyQuery<CommentsData>(
    FETCH_COMMENTS_WITH_PAGINATION,
    {
      onCompleted: (data) => {
        if (data) {
          setComments((comments) => [...comments, ...data.comments]);
        }
      },
    },
  );

  const fetchData = () => {
    getComments({
      variables: {
        limit: COMMENTS_FETCH_LIMIT,
        offset: comments.length,
      },
    });
  };

  useEffect(() => {
    fetchData();
  }, []);

  return {
    count: data?.commentsConnection.aggregate.count || 0,
    fetchComments: fetchData,
    comments,
  };
};

export default useComments;

Dzieje się tu nieco więcej. Po pierwsze importujemy wcześniej zdefiniowane queries oraz potrzebne hooki z Apollo Client, czyli useQuery oraz useLazyQuery. Poza tym wcześniej zdefiniowany typ dla komentarza. Lokalne (zaciągnięte z api) komentarze będziemy trzymać w prostym stanie. Hook wykonuje dwa typy fetchów: z całkowitą ilością komentarzy oraz do paginacji. Do tego pierwszego typu wykorzystamy hook useQuery, który służy do inicjalnego requesta do api w momencie pierwszego zamontowania komponentu. Zwraca on obiekt data, który posiada wewnątrz zagnieżdżone obiekty commentsConnection, a następnie aggregate, który to finalnie posiada wartość count. Taki payload zapewnia Strapi. Liczbę komentarzy z backendu zwracamy bezpośrednio w hooku.

Do paginacji wykorzystujemy hooka useLazyQuery. Różni się od poprzednika tym, że zwraca nam funkcję, którą możemy wykorzystać w dowolnym momencie. Jest to idealne rozwiązanie dla takiego przypadku, jak paginacja. Samą funkcją używamy w inicjalnym renderze za pomocą useEffect. Zapewni to inicjalne komentarze w momencie renderowania komponentu. Funkcję używamy również w komponencie InfiniteScroll w propsie next. Tym samym zapewniamy nowe komentarze w momencie, kiedy zjedziemy na sam dół strony. Do useLazyQuery, w drugim argumencie możemy wysłać obiekt, z kluczem onCompleted. Za każdym razem, kiedy request się powiedzie, odbierzemy dane i dodamy je do lokalnego stanu. Hooki z Apollo Client zwracają także wartości loading i error, ale na potrzeby tutorialu nie wykorzystałem ich tutaj. Jeśli chcesz, możesz zrobić to sam (InfiniteScroll posiada własną obsługę loadera za pomocą propsa loader). 

Tak prezentuje się działające rozwiązanie:

Podsumowanie

Jeśli dotarłeś aż tutaj, to jestem pełen podziwu. To nie był pewnie najkrótszy wpis. Nie było tutaj jakichś mega zaawansowanych technik i tipów. Chciałem pokazać przede wszystkim to, że niekoniecznie musimy za każdym razem pisać rozwiązania od zera. Mamy dostępnych masę narzędzi i technologii, które tylko czekają na to, by je wykorzystać. Tak naprawdę w całym tym procesie tworzenia tej małej aplikacji, musiałem napisać tylko kilka linijek logiki do fetchowania danych. Wszystko inne, począwszy od backendu, po stylowanie, aż do funkcjonalności infinite scrolla, zapewniły mi biblioteki trzecie. Nie ma w tym nic złego, bo czasami gotowe i darmowe rozwiązanie wystarczy. Oczywiście nie ma co popadać w skrajności, ale często, jako developerzy staniemy przed wyborem użycia konkretnego rozwiązania albo napisania takiego od zera. Tutaj zaoszczędziliśmy czas, a czas to pieniądz.

Jeżeli chcesz zobaczyć cały kod i/lub pobrać repo (zarówno backend, jak i api) to tutaj jest link :) Pamiętaj tylko, że jeżeli zechcesz postawić projekt lokalnie, wszelkie dane w Strapi trzeba będzie dodać raz jeszcze (rejestracja admina oraz import danych).

© Damian Kalka 2021
Wszelkie prawa zastrzeżone