Jak tworzę komponenty kompozycyjne i złożone

W tym wpisie pokażę, jak tworzyć w React komponenty kompozycyjne i złożone na przykładzie własnego komponentu tabeli. Kompozycyjność pozwala budować elastyczne, reużywalne komponenty, które łatwo dostosować i rozszerzać. Zobaczymy też, jak skutecznie zarządzać stanem globalnym za pomocą nowoczesnej i przystępnej biblioteki Zustand. Zaczynajmy!

Zadanie

Załóżmy, że mamy stworzyć komponent tabeli do wyświetlania danych o zamówieniach. W komponencie BadOrderList przygotowałem przykład źle zaprojektowanego komponentu tabeli, który prezentuje takie dane.

Przygotowanie mocków i metod pobierających dane

Na początek stwórzmy dwie metody, które pomogą nam pracować z danymi udającymi te pobierane z backendu. Będą symulować pobieranie i przetwarzanie danych, dając realistyczny kontekst do pracy nad komponentem w React.

Tworzenie danych mockowych

Pierwsza metoda wygeneruje tablicę 100 mockowych zamówień. Będzie symulować zestaw danych, jaki zwykle dostajesz z usługi backendowej.

generate-mock-datatypescript
export const sampleStatuses = [
  'Processing',
  'Shipped',
  'Delivered',
  'Cancelled',
] as const;

export const generateMockOrders = (numberOfOrders: number) => {
  const mockOrders = [];
  const sampleCustomers = ['Alice', 'Bob', 'Charlie', 'Diana'];
  const sampleItems = ['Item A', 'Item B', 'Item C'];

  for (let i = 1; i <= numberOfOrders; i++) {
    const order = {
      id: i,
      customer:
        sampleCustomers[Math.floor(Math.random() * sampleCustomers.length)],
      orderDate: new Date(
        2024,
        Math.floor(Math.random() * 12),
        Math.floor(Math.random() * 28)
      ).toLocaleDateString(),
      item: sampleItems[Math.floor(Math.random() * sampleItems.length)],
      quantity: Math.floor(Math.random() * 5) + 1,
      price: Math.floor(Math.random() * 100) + 10,
      status: sampleStatuses[Math.floor(Math.random() * sampleStatuses.length)],
    };
    mockOrders.push(order);
  }

  return mockOrders;
};

// Generate a large set of mock orders
export const ordersMockData = generateMockOrders(100);

Implementacja metod pobierających dane

Druga metoda to funkcja pobierająca wygenerowane dane. Symuluje wywołanie API zwracające zamówienia, co jest kluczowe, żeby pokazać, jak komponent React współpracuje z asynchronicznymi źródłami danych, obsługuje zmiany stanu i aktualizuje UI po ich pobraniu.

Zawiera podstawową logikę pobierania danych, w tym filtrowanie, wyszukiwanie i paginację, czyli typowe wymagania we współczesnych aplikacjach webowych.

  • Parametr limit określa maksymalną liczbę elementów zwracanych w jednym pobraniu. Służy do paginacji i wyznacza rozmiar strony danych.
  • Parametr offset wskazuje, od którego miejsca pobierać dane. W paginacji pozwala wyliczyć początek danej strony. Na przykład przy limicie 10 offset równy 20 oznacza pobieranie od 21. elementu.
  • Parametr search pozwala filtrować dane według określonych kryteriów. Może zawierać filtry takie jak status czy item. Funkcja używa ich, żeby zwrócić tylko dane spełniające podane kryteria.
order-fetch-methodtypescript
import { ordersMockData, sampleStatuses } from '../mocks/orders';
import { OrderItem } from '../types/orders-type';

type OrderStatus = typeof sampleStatuses[number];

type FetchOrdersProps = {
  limit?: number;
  offset?: number;
  search?: {
    status?: OrderStatus;
    item?: string;
  };
};

export type FetchedData = {
  data: OrderItem[];
  total: number;
  limit: number;
  offset: number;
};

export const fetchOrders = ({
  limit = 10,
  offset = 0,
  search,
}: FetchOrdersProps): Promise<FetchedData> => {
  const filteredOrders = ordersMockData.filter(
    (order) =>
      (search?.item
        ? order.item.toLowerCase().includes(search.item.toLowerCase())
        : true) && (search?.status ? order.status === search.status : true)
  );
  const paginatedOrders = filteredOrders.slice(offset, offset + limit);
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        data: paginatedOrders,
        total: filteredOrders.length,
        limit,
        offset,
      });
    }, 1000);
  });
};

Kod źle zaprojektowanego komponentu

W naszej aplikacji będziemy stylować komponenty za pomocą Tailwind CSS, frameworka CSS opartego na klasach narzędziowych. Tailwind upraszcza projektowanie własnych, responsywnych layoutów. Instrukcję instalacji i integracji Tailwind CSS z projektem znajdziesz w oficjalnym przewodniku: Tailwind CSS Installation Guide. Opisuje on krok po kroku konfigurację Tailwind CSS w różnych środowiskach.

Napiszmy źle zaprojektowany komponent, który wyświetla listę zamówień.

bad-list-componenttypescript
import { useEffect, useState } from 'react';
import { fetchOrders } from '../../lib/fetchOrders';
import { sampleStatuses } from '../../mocks/orders';
type OrderItem = {
  customer: string;
  id: number;
  item: string;
  orderDate: string;
  price: number;
  quantity: number;
  status: string;
};

type BadOrderListProps = {
  limit?: number;
};

export const BadOrderList = ({ limit = 10 }: BadOrderListProps) => {
  const [orders, setOrders] = useState<OrderItem[]>([]);
  const [page, setPage] = useState(0);
  const [total, setTotal] = useState();
  const [loading, setLoading] = useState(false);
  const [search, setSearch] = useState({});

  useEffect(() => {
    setLoading(true);
    fetchOrders({ limit, offset: page * limit, search }).then((res) => {
      setOrders(res.data);
      setTotal(res.total);
      setLoading(false);
    });
  }, [page, search]);

  const handleSearchChange = (e) => {
    setSearch({ ...search, [e.target.name]: e.target.value });
  };

  const nextPage = () => setPage((prev) => prev + 1);
  const prevPage = () => setPage((prev) => (prev > 0 ? prev - 1 : 0));
  return (
    <div className="px-8 m-auto">
      <h2 className="py-8 text-3xl text-gray-900 dark:text-white">
        Wrong List
      </h2>
      <div className="filters mb-8 flex gap-6">
        <div>
          <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
            Search by items
          </label>
          <input
            type="text"
            name="item"
            className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
            value={search.item}
            onChange={handleSearchChange}
          />
        </div>
        <div>
          <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
            Select an option
          </label>
          <select
            name="status"
            value={search.status}
            onChange={handleSearchChange}
            className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
          >
            <option value={''}>Choose a status</option>
            {sampleStatuses.map((status) => {
              return (
                <option value={status} key={status}>
                  {status}
                </option>
              );
            })}
          </select>
        </div>
      </div>
      <div className="relative overflow-x-auto">
        <table className="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
          <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
            <tr>
              <th scope="col" className="px-6 py-3">
                ID
              </th>
              <th scope="col" className="px-6 py-3">
                Customer
              </th>
              <th scope="col" className="px-6 py-3">
                Item
              </th>
              <th scope="col" className="px-6 py-3">
                Order Date
              </th>
              <th scope="col" className="px-6 py-3">
                Price
              </th>
              <th scope="col" className="px-6 py-3">
                Status
              </th>
            </tr>
          </thead>
          <tbody>
            {loading
              ? [...Array(limit)].map((_, rowIndex) => {
                  return (
                    <tr
                      key={rowIndex}
                      className="bg-white border-b dark:bg-gray-800 dark:border-gray-700"
                    >
                      {[...Array(6)].map((_, columnIndex) => {
                        return (
                          <td
                            key={columnIndex}
                            scope="row"
                            className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
                          >
                            <div className="h-4 bg-gray-200 rounded-full dark:bg-gray-700 w-auto mb-1"></div>
                          </td>
                        );
                      })}
                    </tr>
                  );
                })
              : orders.map((order) => {
                  return (
                    <tr
                      key={order.id}
                      className="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600"
                    >
                      <th
                        scope="row"
                        className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
                      >
                        {order.id}
                      </th>
                      <td className="px-6 py-4">{order.customer}</td>
                      <td className="px-6 py-4">{order.item}</td>
                      <td className="px-6 py-4"> {order.orderDate}</td>
                      <td className="px-6 py-4">${order.price}</td>
                      <td className="px-6 py-4">{order.status}</td>
                    </tr>
                  );
                })}
          </tbody>
        </table>

        <div className="flex flex-col items-center mt-8">
          <span className="text-sm text-gray-700 dark:text-gray-400">
            Showing{' '}
            <span className="font-semibold text-gray-900 dark:text-white">
              {page + 1}
            </span>{' '}
            to{' '}
            <span className="font-semibold text-gray-900 dark:text-white">
              {limit}
            </span>{' '}
            of{' '}
            <span className="font-semibold text-gray-900 dark:text-white">
              {total}
            </span>{' '}
            Entries
          </span>
          <div className="inline-flex mt-2 xs:mt-0">
            <button
              onClick={prevPage}
              className="flex items-center justify-center px-3 h-8 text-sm font-medium text-white bg-gray-800 rounded-s hover:bg-gray-900 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
            >
              <svg
                className="w-3.5 h-3.5 me-2 rtl:rotate-180"
                aria-hidden="true"
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 14 10"
              >
                <path
                  stroke="currentColor"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth="2"
                  d="M13 5H1m0 0 4 4M1 5l4-4"
                />
              </svg>
              Prev
            </button>
            <button
              onClick={nextPage}
              className="flex items-center justify-center px-3 h-8 text-sm font-medium text-white bg-gray-800 border-0 border-s border-gray-700 rounded-e hover:bg-gray-900 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
            >
              Next
              <svg
                className="w-3.5 h-3.5 ms-2 rtl:rotate-180"
                aria-hidden="true"
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 14 10"
              >
                <path
                  stroke="currentColor"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth="2"
                  d="M1 5h12m0 0L9 1m4 4L9 9"
                />
              </svg>
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

Na pierwszy rzut oka ten komponent może wyglądać na poprawny. Przy bliższym spojrzeniu wychodzi jednak kilka technicznych niedociągnięć.

wymieńmy błędy, które często pojawiają się w takim komponencie:

Problem 1. - Brak elastyczności i reużywalności komponentu

Przede wszystkim komponentowi BadOrderList brakuje elastyczności. Na sztywno ustawiony limit paginacji i wyszukiwanie mocno związane z komponentem utrudniają jego ponowne użycie. W idealnym scenariuszu komponenty powinny być jak najbardziej generyczne i reużywalne, tak by dało się je dopasować do różnych kontekstów i typów danych.

Problem 2. - Nieefektywne zarządzanie stanem

Zarządzanie stanem też pozostawia wiele do życzenia. Stany orders, page, total, loading i search są obsługiwane w samym komponencie, przez co kod puchnie i trudniej nad nim zapanować. Taki projekt nie wykorzystuje w pełni modelu kompozycji w React, w którym stan można wynieść na zewnątrz albo obsłużyć elegancko za pomocą kontekstu lub wzorców podobnych do Reduxa.

Problem 3. - Przekombinowane renderowanie UI

Logika renderowania, zwłaszcza skeletonów ładowania i paginacji, jest bardziej skomplikowana, niż trzeba. Taka złożoność sprzyja błędom i utrudnia zrozumienie oraz utrzymanie kodu. Prostsze podejście, na przykład z własnymi hookami albo osobnymi komponentami, mogłoby znacznie uprościć renderowanie.

Problem 4. - Słaba separacja odpowiedzialności

Komponent robi zbyt wiele naraz: pobiera dane, zarządza stanem, renderuje UI i obsługuje interakcje użytkownika. Brak separacji odpowiedzialności utrudnia debugowanie i testowanie. Najlepiej byłoby rozdzielić te zadania na osobne komponenty lub hooki, zgodnie z zasadą pojedynczej odpowiedzialności.

Problem 5. - Style na sztywno i brak obsługi motywów

Bezpośrednie użycie klas Tailwind CSS w komponencie ogranicza możliwość stosowania motywów i dynamicznego stylowania. Lepiej byłoby wydzielić style, co daje większą elastyczność i ułatwia dopasowanie do różnych design systemów.

Lepsze podejście: projektujemy komponent w React od nowa

Jak wspomniałem, nasz komponent listy zamówień wymaga poprawy. Zacznijmy od dopracowania podejścia i zdefiniowania stanu.

Głównym celem jest stworzenie komponentu tabeli, którego łatwo użyć ponownie. Świetnym rozwiązaniem jest przechowywanie jego stanu w globalnym store. Ma to kilka zalet:

  1. Scentralizowane zarządzanie: Przechowywanie stanu w globalnym store centralizuje zarządzanie stanem tabeli. Przydaje się to szczególnie w większych aplikacjach, w których dostępu do tego samego stanu może potrzebować wiele komponentów.
  2. Spójność stanu: Zapewnia spójność w całej aplikacji. Gdy stan jest zarządzany globalnie, każda zmiana od razu trafia do wszystkich komponentów, które z niego korzystają.
  3. Reużywalność i skalowalność: Ze stanem w globalnym store łatwiej tworzyć reużywalne komponenty tabel. Możesz bez problemu użyć wielu tabel z różnymi stanami i nie martwić się o konflikty.

Tworzymy store tabeli

Do stworzenia store'a instaluję bibliotekę zustand:

zustand instalationjavascript
npm install zustand

Teraz zdefiniujmy stan naszej tabeli.

Kluczowe jest tu nadanie każdej tabeli konkretnego ID, dzięki czemu możemy osobno zarządzać jej stanem w stanie globalnym. To podstawa przy implementacji paginacji, filtrowania i innych interakcji właściwych dla danej tabeli. Unikalne ID gwarantuje, że dane, bieżąca strona, filtry wyszukiwania i pozostałe elementy stanu każdej tabeli są zarządzane niezależnie i wydajnie.

Oto kod zarządzania stanem z użyciem biblioteki Zustand:

table-storetypescript
import { create } from 'zustand';

export type TableState = {
  page: number;
  limit: number;
  search: Record<string, any>;
};

type TableStoreState = {
  tables: Record<string, TableState>;
};

type TableStoreActions = {
  initTable: (tableId: string, initialState?: TableState) => void;
  setPage: (tableId: string, page: number) => void;
  setLimit: (tableId: string, limit: number) => void;
  setSearch: (tableId: string, search: Record<string, any>) => void;
};

export const useTableStore = create<TableStoreState & TableStoreActions>(
  (set) => ({
    tables: {},

    // Initialize or update a table's state
    initTable: (tableId, initialState = { page: 0, limit: 10, search: {} }) =>
      set((state) => ({
        tables: {
          ...state?.tables,
          [tableId]: state.tables[tableId]
            ? { ...state.tables[tableId], ...initialState }
            : initialState,
        },
      })),
    // Update page for a specific table
    setPage: (tableId, page) =>
      set((state) => ({
        tables: {
          ...state.tables,
          [tableId]: { ...state.tables[tableId], page },
        },
      })),

    // Update limit for a specific table
    setLimit: (tableId, limit) =>
      set((state) => ({
        tables: {
          ...state.tables,
          [tableId]: { ...state.tables[tableId], limit },
        },
      })),

    // Update search for a specific table
    setSearch: (tableId, search) =>
      set((state) => ({
        tables: {
          ...state.tables,
          [tableId]: { ...state.tables[tableId], search },
        },
      })),
  })
);

W tym rozwiązaniu używamy Zustanda do stworzenia globalnego store'a (useTableStore), który przechowuje stan wielu tabel. Każda tabela ma unikalny tableId, dzięki czemu operacje takie jak ustawienie bieżącej strony (setPage), zmiana liczby wierszy na stronie (setLimit) czy zmiana filtrów wyszukiwania (setSearch) dotyczą tylko wskazanej tabeli. To modularny i łatwy w utrzymaniu sposób obsługi stanu w złożonych aplikacjach, w których wiele instancji tego samego komponentu musi działać niezależnie.

Tworzymy komponent tabeli

Mamy już stan, więc możemy przejść do stworzenia komponentu tabeli.

common-table-componenttsx
type TableProps = React.HTMLAttributes<HTMLTableElement> & {
  children: React.ReactNode;
};

export const Table = ({ children, ...props }: TableProps) => {
  return <table {...props}>{children}</table>;
};

type THeadProps = React.HTMLAttributes<HTMLTableSectionElement> & {
  renderHeader: () => React.ReactNode;
};

export const THead = ({ renderHeader, ...props }: THeadProps) => {
  return <thead {...props}>{renderHeader()}</thead>;
};

Table.THead = THead;

type TBody<T> = {
  loading?: boolean;
  data?: T[];
  renderRow: (item: T, index: number) => React.ReactNode;
  limit: number;
};

export const TBody = <T extends unknown>({
  loading,
  data,
  renderRow,
  limit,
}: TBody<T>) => {
  const rows = data?.map((item, index) => renderRow(item, index));

  return <tbody>{loading ? <SkeletonRow limit={limit} /> : rows}</tbody>;
};

Table.TBody = TBody;

type SkeletonRowProps = {
  limit: number;
};

const SkeletonRow = ({ limit }: SkeletonRowProps) => {
  return (
    <>
      {[...Array(limit)].map((_, rowIndex) => (
        <tr
          key={rowIndex}
          className="bg-white border-b dark:bg-gray-800 dark:border-gray-700"
        >
          {[...Array(6)].map((_, columnIndex) => (
            <td
              key={columnIndex}
              className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
            >
              <div className="h-4 bg-gray-200 rounded-full dark:bg-gray-700 w-auto mb-1"></div>
            </td>
          ))}
        </tr>
      ))}
    </>
  );
};

Powyższy kod definiuje elastyczny komponent tabeli w React. Table to komponent funkcyjny, który przyjmuje children i dodatkowe atrybuty HTML, a renderuje standardowy element tabeli. Dzięki propsom łatwo go ponownie użyć i dostosować.

  • Table (komponent): Pełni rolę kontenera tabeli. Przyjmuje dowolne atrybuty HTML i elementy potomne, co czyni go bardzo uniwersalnym.
  • THead (komponent): Odpowiada za renderowanie nagłówka tabeli. Przyjmuje w propsach funkcję renderHeader, która zwraca węzły nagłówka, co daje swobodę w doborze jego zawartości.
  • TBody (komponent): Obsługuje ciało tabeli. Radzi sobie ze stanem ładowania i dynamicznym renderowaniem danych. Funkcja renderRow pozwala dowolnie renderować każdy wiersz na podstawie przekazanych danych.
  • SkeletonRow: Komponent zastępczy wyświetlany podczas ładowania danych. Poprawia doświadczenie użytkownika, sygnalizując, że dane są pobierane.

Taki projekt sprzyja reużywalności i separacji odpowiedzialności, dzięki czemu komponent tabeli można dopasować do różnych typów danych i wymagań UI. Render propsy (renderHeader i renderRow) dodatkowo zwiększają jego elastyczność, pozwalając renderować zawartość zgodnie z potrzebami konkretnych danych.

Tworzymy komponent paginacji

Mamy już komponent tabeli, więc napiszmy komponent paginacji, który umożliwi sprawne poruszanie się po stronach tabeli. Komponent Pagination korzysta z useTableStore do zarządzania stanem bieżącej strony. Udostępnia przyciski przejścia do poprzedniej i następnej strony oraz pokazuje bieżącą stronę i łączną liczbę wpisów. Funkcje prevPage i nextPage zmieniają bieżącą stronę w granicach dostępnych danych. Dzięki temu użytkownik łatwo się porusza i od razu widzi, ile danych zawiera tabela.

Paginationtsx
import { useTableStore } from '../../store/table.store';

type PaginationProps = {
  page: number;
  initId: string;
  limit: number;
  total: number;
};

export const Pagination = ({ page, initId, limit, total }: PaginationProps) => {
  const { setPage } = useTableStore();

  const prevPage = () => {
    setPage(initId, Math.max(0, page - 1));
  };

  const nextPage = () => {
    setPage(initId, page + 1);
  };

  return (
    <div className="flex flex-col items-center mt-8">
      <span className="text-sm text-gray-700 dark:text-gray-400">
        Showing{' '}
        <span className="font-semibold text-gray-900 dark:text-white">
          {page + 1}
        </span>{' '}
        to{' '}
        <span className="font-semibold text-gray-900 dark:text-white">
          {limit}
        </span>{' '}
        of{' '}
        <span className="font-semibold text-gray-900 dark:text-white">
          {total}
        </span>{' '}
        Entries
      </span>
      <div className="inline-flex mt-2 xs:mt-0">
        <button
          onClick={prevPage}
          className="flex items-center justify-center px-3 h-8 text-sm font-medium text-white bg-gray-800 rounded-s hover:bg-gray-900 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
        >
          <svg
            className="w-3.5 h-3.5 me-2 rtl:rotate-180"
            aria-hidden="true"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 14 10"
          >
            <path
              stroke="currentColor"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              d="M13 5H1m0 0 4 4M1 5l4-4"
            />
          </svg>
          Prev
        </button>
        <button
          onClick={nextPage}
          className="flex items-center justify-center px-3 h-8 text-sm font-medium text-white bg-gray-800 border-0 border-s border-gray-700 rounded-e hover:bg-gray-900 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
        >
          Next
          <svg
            className="w-3.5 h-3.5 ms-2 rtl:rotate-180"
            aria-hidden="true"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 14 10"
          >
            <path
              stroke="currentColor"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              d="M1 5h12m0 0L9 1m4 4L9 9"
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

Tworzymy komponent filtrów

Teraz odtworzymy filtry, ale przeniesiemy je do osobnego komponentu. Oznacza to wydzielenie logiki filtrowania i elementów UI z obecnego kodu i zamknięcie ich w niezależnym, reużywalnym komponencie.

order-list-filterstsx
import { sampleStatuses } from '../../mocks/orders';
type OrderListFiltersPRops = {
  handleSearchChange: (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => void;
  search: {
    item?: string;
    status?: string;
  };
};
export const OrderListFilters = ({
  handleSearchChange,
  search,
}: OrderListFiltersPRops) => {
  return (
    <div className="filters mb-8 flex gap-6">
      <div>
        <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
          Search by items
        </label>
        <input
          type="text"
          name="item"
          className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
          value={search.item}
          onChange={handleSearchChange}
        />
      </div>
      <div>
        <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
          Select an option
        </label>
        <select
          name="status"
          value={search.status}
          onChange={handleSearchChange}
          className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
        >
          <option value={''}>Choose a status</option>
          {sampleStatuses.map((status) => {
            return (
              <option value={status} key={status}>
                {status}
              </option>
            );
          })}
        </select>
      </div>
    </div>
  );
};

Komponent OrderListFilters to dobrze ustrukturyzowane podejście do filtrowania danych w tabeli. Oto jego kluczowe elementy i powody, dla których to rozwiązanie się sprawdza:

  1. Pole wyszukiwania: Pozwala filtrować tabelę po nazwach pozycji. Pole jest powiązane ze stanem search.item, więc wyszukiwana fraza jest z nim zawsze zsynchronizowana.
  2. Lista statusów: Umożliwia filtrowanie tabeli po statusie zamówienia. Element select jest wypełniany opcjami na podstawie sampleStatuses i również powiązany ze stanem search.status.
  3. Obsługa zdarzeń (handleSearchChange): Zarówno pole tekstowe, jak i lista używają tej samej funkcji handleSearchChange do aktualizacji stanu wyszukiwania. Funkcja obsługuje zmiany w różnych typach pól, dzięki czemu jest elastyczna i reużywalna.
  4. Modularność i reużywalność: Ten komponent to przykład modularnego i reużywalnego elementu UI, którego można użyć wszędzie tam, gdzie w aplikacji potrzebne jest filtrowanie.

Ogólnie ten komponent pokazuje czysty kod, skuteczne zarządzanie stanem i przyjazny interfejs, dzięki czemu jest solidnym wyborem do obsługi filtrów tabeli w aplikacji React.

Tworzymy własny hook

Stworzenie hooka useOrders to ważny kolejny krok, bo pozwala wydzielić i scentralizować logikę pobierania zamówień.

Do usprawnienia pobierania danych użyjemy useSWR. Warto podkreślić, że useSWR w React bardzo pomaga w cache'owaniu żądań. useSWR to potężny hook, który automatycznie obsługuje cache, czyli przechowuje wyniki wywołań API i używa ich ponownie, gdy to samo żądanie padnie jeszcze raz. Taki mechanizm poprawia wydajność, bo ogranicza liczbę wywołań API, a to oznacza krótszy czas ładowania i oszczędniejsze korzystanie z sieci. Szczególnie przydaje się to, gdy dane rzadko się zmieniają albo gdy chcesz zachować płynność działania, od razu pokazując wcześniej pobrane dane.

Omówmy kluczowe elementy tego hooka:

  1. Integracja z useSWR: useSWR to potężny hook do pobierania danych. Od razu obsługuje cache, rewalidację, śledzenie fokusu i ponawianie po błędach.
  2. Dynamiczne generowanie klucza: Hook generuje unikalny klucz na podstawie parametrów page, limit i search. Dzięki temu SWR pobiera i rewaliduje dane tylko wtedy, gdy te parametry się zmienią, co przekłada się na wydajne pobieranie i cache'owanie.
  3. Funkcja pobierająca: Hook wywołuje fetchOrders, funkcję pobierającą dane z mockowego backendu. Symuluje ona zachowanie prawdziwego wywołania API, łącznie z filtrowaniem i paginacją na podstawie przekazanych parametrów.
  4. useEffect do inicjalizacji: Hook useEffect służy do zainicjalizowania stanu tabeli wartościami domyślnymi przy montowaniu komponentu. Gwarantuje, że tabela startuje z poprawnym stanem początkowym (page, limit i search), co poprawia doświadczenie użytkownika już od pierwszego renderowania.
  5. Wartość zwracana: Hook zwraca dane oraz stany błędu i ładowania zarządzane przez useSWR, dając komponentom prosty interfejs do renderowania danych, pokazywania wskaźników ładowania i obsługi błędów.

Krótko mówiąc, ten własny hook oparty na useSWR upraszcza pobieranie, cache'owanie i zarządzanie stanem danych o zamówieniach. Zamyka złożone mechanizmy w łatwiejszej w użyciu, reużywalnej formie, co znacząco usprawnia obsługę danych w aplikacji React.

useOrderstypescript
import useSWR from 'swr';
import { FetchedData, fetchOrders } from '../lib/fetchOrders';
import { useTableStore } from '../store/table.store';
import { useEffect } from 'react';

type UseOrderProps = {
  initId: string;
  limit: number;
  page: number;
  search: {
    item?: string;
    status?: string;
  };
};

export const useOrders = ({ initId, limit, page, search }: UseOrderProps) => {
  const { initTable } = useTableStore();

  useEffect(() => {
    initTable(initId, { page: 0, limit, search: {} });
  }, [initId, limit, initTable]);
  return useSWR<FetchedData>(
    () => (page != null ? { limit, offset: page * limit, search } : null),
    fetchOrders
  );
};

Takie podejście ma kilka zalet:

  1. Separacja odpowiedzialności: Przeniesienie logiki pobierania danych do własnego hooka sprawia, że kod komponentu jest czysty i skupia się na renderowaniu oraz interakcjach użytkownika. Dzięki temu komponenty łatwiej zrozumieć i utrzymać.
  2. Reużywalność: Hooka można użyć w różnych komponentach, które potrzebują danych o zamówieniach, co ogranicza powielanie kodu.
  3. Lepsza czytelność: Skupienie logiki w jednym hooku poprawia czytelność. Inni programiści łatwo zrozumieją, co robi, a to ułatwia pracę z kodem.
  4. Prostsze zarządzanie stanem

Tworzymy komponent listy zamówień: składamy wszystko w całość

Złóżmy wszystko w całość! Umieścimy wszystkie zdefiniowane wcześniej komponenty w nadrzędnym komponencie OrderList i przekażemy każdemu odpowiednie propsy. Dzięki temu komponenty będą ze sobą współpracować, tworząc spójny i funkcjonalny interfejs. Łącząc OrderListFilters, Table i Pagination za pomocą propsów, otrzymujemy kompletną, interaktywną tabelę, która jest wygodna w obsłudze i wydajnie radzi sobie z danymi.

order-listtsx
import { useTableStore } from '../../store/table.store';
import { Table } from '../common/table';
import { OrderItem } from '../../types/orders-type';
import { Pagination } from '../common/pagination';
import { OrderListFilters } from './order-list-filters';
import { useOrders } from '../../hooks/useOrders';

export const OrderList = ({ limit = 10 }) => {
  const initId = 'order-list';
  const { tables, setSearch } = useTableStore();
  const tableState = tables[initId];
  const { page, search } = tableState || { page: 0, search: {} };
  const { data, error, isLoading } = useOrders({ initId, limit, page, search });

  const handleSearchChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    setSearch(initId, { ...search, [e.target.name]: e.target.value });
  };

  return (
    <div className="px-8 m-auto">
      <h2 className="py-8 text-3xl text-gray-900 dark:text-white">
        Good example
      </h2>
      {error && (
        <div className="error-message">
          <p>Failed to load orders. Please try again later.</p>
        </div>
      )}
      {!error && (
        <>
          <OrderListFilters
            handleSearchChange={handleSearchChange}
            search={search}
          />
          <Table className="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
            <Table.THead
              renderHeader={renderHeader}
              className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
            />
            <Table.TBody
              loading={isLoading}
              data={data?.data || []}
              renderRow={renderRow}
              limit={limit}
            />
          </Table>
          <Pagination
            page={page}
            limit={limit}
            initId={initId}
            total={data?.total || 0}
          />
        </>
      )}
    </div>
  );
};

const renderHeader = () => (
  <tr>
    <th scope="col" className="px-6 py-3">
      ID
    </th>
    <th scope="col" className="px-6 py-3">
      Customer
    </th>
    <th scope="col" className="px-6 py-3">
      Item
    </th>
    <th scope="col" className="px-6 py-3">
      Order Date
    </th>
    <th scope="col" className="px-6 py-3">
      Price
    </th>
    <th scope="col" className="px-6 py-3">
      Status
    </th>
  </tr>
);

const renderRow = (order: OrderItem, index: number) => {
  return (
    <tr
      key={index}
      className="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600"
    >
      <th
        scope="row"
        className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
      >
        {order.id}
      </th>
      <td className="px-6 py-4">{order.customer}</td>
      <td className="px-6 py-4">{order.item}</td>
      <td className="px-6 py-4"> {order.orderDate}</td>
      <td className="px-6 py-4">${order.price}</td>
      <td className="px-6 py-4">{order.status}</td>
    </tr>
  );
};

Stworzony przez nas komponent OrderList to świetny przykład kompletnego, dobrze ustrukturyzowanego komponentu React. Sprawnie łączy różne elementy:

  1. Integracja z useTableStore: Zarządza stanem tabeli, takim jak bieżąca strona i parametry wyszukiwania.
  2. Obsługa błędów: Wyświetla komunikat, gdy pobieranie danych się nie powiedzie, dzięki czemu użytkownik wie o problemie.
  3. Filtry w OrderListFilters: Pozwalają filtrować tabelę według kryteriów takich jak pozycja i status.
  4. Pobieranie danych przez useOrders: Odpowiada za pobieranie zamówień, w tym paginację i wyszukiwanie.
  5. Wyświetlanie danych przez Table (komponent): Wykorzystuje elastyczny komponent tabeli. Składa się on z THead na nagłówki i TBody na treść, a oba można dostosować.
  6. Sterowanie paginacją przez Pagination (komponent): Pozwala użytkownikom poruszać się między stronami danych.
  7. Funkcje renderujące (renderHeader i renderRow): Określają, jak renderują się poszczególne wiersze i nagłówki, dzięki czemu tabelę można dopasować do różnych struktur danych.

Ten komponent pokazuje spójne, modularne podejście do budowania złożonych interfejsów w React, z wyraźną separacją odpowiedzialności i reużywalnymi komponentami.

Na koniec udostępniam link do omawianego przykładu. Pod tym linkiem możesz zobaczyć, jak pliki są rozłożone w folderach, i sprawdzić działanie zaimplementowanych komponentów: Zobacz przykład na StackBlitz. Dzięki temu zobaczysz w praktyce, jak komponenty są zorganizowane i połączone w realnym scenariuszu.


Dzięki za lekturę! Mam nadzieję, że te wzorce pomogą Ci pisać lepsze komponenty React. Jeśli masz pytania, śmiało się odezwij. Miłego kodowania!