Kurs JavaScript i React · Moduł 12: React i TypeScript

Komponenty Generyczne - Uniwersalne Systemy Nawigacji

5 min czytania
W tej lekcji5

Masz komponent listy planet. Po tygodniu potrzebujesz listy gwiazd, potem członków załogi, potem statków. Kopiujesz komponent czwarty raz i zmieniasz tylko typ - a każdy błąd trzeba odtąd poprawiać w czterech miejscach. Wyobraź sobie zamiast tego uniwersalny system nawigacji na statku kosmicznym, który może wyświetlać dane o planetach, gwiazdach, statkach - cokolwiek mu podasz. W TypeScript takie "uniwersalne" komponenty tworzymy dzięki generykom.

Czym są generyki?

Generyki to parametry typów - pozwalają tworzyć komponenty i funkcje, które działają z różnymi typami danych, zachowując pełną kontrolę typów. Parametr typu zapisujesz w nawiasach ostrych, zwykle jedną wielką literą T (od "type"). To miejsce na typ, który zostanie podany dopiero przy użyciu - jak zmienna, tylko dla typów.

W przykładzie używamy interfejsów Planet i Star z lekcji o props, a planets i stars to tablice takich obiektów:

1// Bez generyków - trzeba pisać osobne funkcje
2function getFirstPlanet(items: Planet[]): Planet {
3  return items[0];
4}
5function getFirstStar(items: Star[]): Star {
6  return items[0];
7}

Obie funkcje robią dokładnie to samo, różnią się tylko typem. Generyk zamienia je w jedną:

1// Z generykami - jedna funkcja dla wszystkich typów
2function getFirst<T>(items: T[]): T {
3  return items[0];
4}
5
6const planet = getFirst<Planet>(planets);  // zwraca Planet
7const star = getFirst<Star>(stars);        // zwraca Star
8const name = getFirst<string>(["a", "b"]); // zwraca string

Zapis getFirst<T>(items: T[]): T czytamy: "dla dowolnego typu T przyjmij tablicę T i zwróć jedno T". Typ nie ginie po drodze - planet ma typ Planet, więc edytor podpowie jego pola. W praktyce nawiasy przy wywołaniu możesz pominąć, bo TypeScript wydedukuje T z argumentu: getFirst(planets) da ten sam wynik.

Generyczny komponent listy

Jednym z najczęstszych zastosowań generyków w React jest uniwersalny komponent listy. Lista potrzebuje jednej gwarancji: każdy element musi mieć id, bo React wymaga unikalnego key. Taką gwarancję zapisujemy słowem extends - zapis T extends HasId oznacza "T może być czymkolwiek, byle miało pole id". To tak zwane ograniczenie (constraint).

1// Każdy element musi mieć id do użycia jako key
2interface HasId {
3  id: number | string;
4}
5
6interface ListProps<T extends HasId> {
7  items: T[];
8  renderItem: (item: T) => React.ReactNode;
9  emptyMessage?: string;
10}

Props renderItem to tak zwany render prop: funkcja, którą rodzic przekazuje, aby zdecydować, jak narysować pojedynczy element. Sama lista nie wie nic o planetach - wie tylko, jak je ułożyć:

1function List<T extends HasId>({ items, renderItem, emptyMessage = "Brak danych" }: ListProps<T>) {
2  if (items.length === 0) {
3    return <p>{emptyMessage}</p>;
4  }
5
6  return (
7    <ul>
8      {items.map(item => (
9        <li key={item.id}>{renderItem(item)}</li>
10      ))}
11    </ul>
12  );
13}

Dzięki ograniczeniu item.id jest dozwolone w key. Gdybyś spróbował odczytać item.name, TypeScript zaprotestuje - lista nie ma gwarancji, że takie pole istnieje. Uwaga techniczna: w pliku .tsx funkcja strzałkowa z generykiem wymaga zapisu <T,> z przecinkiem, żeby kompilator nie pomylił jej ze znacznikiem JSX. Deklaracja function tego problemu nie ma.

Użycie generycznego komponentu

Przygotujmy dwa różne rodzaje danych. Planety mają id liczbowe, członkowie załogi - tekstowe, i oba warianty spełniają HasId:

1interface Planet {
2  id: number;
3  name: string;
4  type: string;
5}
6
7interface CrewMember {
8  id: string;
9  name: string;
10  rank: string;
11}

Teraz ten sam komponent List wyświetla obie kolekcje. Zapis <List<Planet> jawnie podaje typ, choć TypeScript wydedukowałby go z items:

1function Dashboard() {
2  const planets: Planet[] = [
3    { id: 1, name: "Mars", type: "skalista" },
4    { id: 2, name: "Jupiter", type: "gazowy" }
5  ];
6
7  const crew: CrewMember[] = [
8    { id: "c1", name: "Nova", rank: "Kapitan" },
9    { id: "c2", name: "Ra", rank: "Nawigator" }
10  ];
11
12  return (
13    <div>
14      <h2>Planety</h2>
15      <List<Planet>
16        items={planets}
17        renderItem={(planet) => <span>{planet.name} ({planet.type})</span>}
18      />
19
20      <h2>Załoga</h2>
21      <List<CrewMember>
22        items={crew}
23        renderItem={(member) => <span>{member.name} - {member.rank}</span>}
24      />
25    </div>
26  );
27}

W renderItem parametr planet ma typ Planet, a member - CrewMember, bez żadnych adnotacji. Komponent List nie zmienił się ani o linijkę.

Generyczny komponent Select

Ten sam wzorzec działa dla pola wyboru. Ograniczenie wymaga pól value i label, a onChange oddaje rodzicowi cały wybrany obiekt, z zachowaniem jego dokładnego typu. Handler zmiany korzysta z ChangeEvent z poprzedniej lekcji:

1interface SelectOption {
2  value: string;
3  label: string;
4}
5
6interface SelectProps<T extends SelectOption> {
7  options: T[];
8  value: string;
9  onChange: (option: T) => void;
10  placeholder?: string;
11}
12
13function Select<T extends SelectOption>({
14  options, value, onChange, placeholder
15}: SelectProps<T>) {
16  const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
17    const selected = options.find(opt => opt.value === e.target.value);
18    if (selected) onChange(selected);
19  };
20
21  return (
22    <select value={value} onChange={handleChange}>
23      {placeholder && <option value="">{placeholder}</option>}
24      {options.map(opt => (
25        <option key={opt.value} value={opt.value}>
26          {opt.label}
27        </option>
28      ))}
29    </select>
30  );
31}

Metoda find może zwrócić undefined, dlatego if (selected) jest konieczne - TypeScript nie pozwoli przekazać możliwego undefined do onChange.

Ograniczenia generyków (constraints)

Słowo kluczowe extends pozwala ograniczyć, jakie typy są akceptowane. Ograniczeniem może być typ zapisany w miejscu albo nazwany interfejs:

1// T musi mieć pole 'name'
2function displayName<T extends { name: string }>(item: T): string {
3  return item.name;
4}
5
6// T musi implementować interfejs Sortable
7interface Sortable {
8  sortOrder: number;
9}
10
11function sortItems<T extends Sortable>(items: T[]): T[] {
12  return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
13}

Ograniczenie to minimum, nie dokładny kształt: obiekt może mieć dodatkowe pola i zostaną one zachowane w typie wyniku. [...items] tworzy kopię, więc sortowanie nie modyfikuje oryginalnej tablicy. Moja rada: sięgaj po generyk dopiero wtedy, gdy ten sam komponent naprawdę obsługuje dwa różne typy danych - wcześniej wystarczy zwykły interfejs. W projekcie końcowym zbudujesz generyczną tabelę danych.

Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Ograniczenie generyczne - kazdy element musi miec id
4interface HasId {
5  id: number | string;
6}
7
8// Generyczny komponent listy
9interface ListProps<T extends HasId> {
10  items: T[];
11  renderItem: (item: T) => React.ReactNode;
12  title: string;
13  emptyMessage?: string;
14}
15
16function List<T extends HasId>({ items, renderItem, title, emptyMessage = "Brak elementow" }: ListProps<T>) {
17  return (
18    <div className="list-container">
19      <h2>{title} ({items.length})</h2>
20      {items.length === 0 ? (
21        <p className="empty">{emptyMessage}</p>
22      ) : (
23        <ul className="list">
24          {items.map(item => (
25            <li key={item.id} className="list-item">{renderItem(item)}</li>
26          ))}
27        </ul>
28      )}
29    </div>
30  );
31}
32
33// Rozne typy danych
34interface Planet {
35  id: number;
36  name: string;
37  type: string;
38  habitable: boolean;
39}
40
41interface CrewMember {
42  id: string;
43  name: string;
44  rank: string;
45  specialization: string;
46}
47
48interface Mission {
49  id: number;
50  codeName: string;
51  status: "planned" | "active" | "completed";
52  destination: string;
53}
54
55function App() {
56  const [planets] = useState<Planet[]>([
57    { id: 1, name: "Kepler-442b", type: "skalista", habitable: true },
58    { id: 2, name: "HD 209458 b", type: "gazowa", habitable: false },
59    { id: 3, name: "Proxima Centauri b", type: "skalista", habitable: true },
60  ]);
61
62  const [crew] = useState<CrewMember[]>([
63    { id: "c1", name: "Nova", rank: "Kapitan", specialization: "Nawigacja" },
64    { id: "c2", name: "Ra", rank: "Nawigator", specialization: "Astrofizyka" },
65    { id: "c3", name: "Astro", rank: "Inzynier", specialization: "Silniki" },
66  ]);
67
68  const [missions] = useState<Mission[]>([
69    { id: 1, codeName: "DAWN", status: "active", destination: "Mars" },
70    { id: 2, codeName: "HORIZON", status: "planned", destination: "Jupiter" },
71  ]);
72
73  const statusColors = { planned: "#f59e0b", active: "#22c55e", completed: "#6b7280" };
74
75  return (
76    <div className="app">
77      <h1>Generyczne Komponenty</h1>
78
79      <List<Planet>
80        title="Planety"
81        items={planets}
82        renderItem={(planet) => (
83          <div className="item-content">
84            <strong>{planet.name}</strong>
85            <span className="detail">{planet.type}</span>
86            <span className={"badge " + (planet.habitable ? "green" : "red")}>
87              {planet.habitable ? "Zamieszkiwalna" : "Niezamieszkiwalna"}
88            </span>
89          </div>
90        )}
91      />
92
93      <List<CrewMember>
94        title="Zaloga"
95        items={crew}
96        renderItem={(member) => (
97          <div className="item-content">
98            <strong>{member.name}</strong>
99            <span className="detail">{member.rank}</span>
100            <span className="spec">{member.specialization}</span>
101          </div>
102        )}
103      />
104
105      <List<Mission>
106        title="Misje"
107        items={missions}
108        renderItem={(mission) => (
109          <div className="item-content">
110            <strong>{mission.codeName}</strong>
111            <span className="detail">{mission.destination}</span>
112            <span style={{ color: statusColors[mission.status] }}>
113              {mission.status.toUpperCase()}
114            </span>
115          </div>
116        )}
117      />
118    </div>
119  );
120}
121
122export default App;

Pamiętaj: generyk to uniwersalne dokowanie - każdy statek, który spełnia wymagania portu, zadokuje bez przebudowy stacji.

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co oznacza zapis <T> w deklaracji function getFirst<T>(items: T[]): T?

  2. 2. Co oznacza zapis <T extends { id: number }> w generycznym komponencie React?

Zadania praktyczne w grze

  • Układanie w pionie

    Ułóż kolejność kroków tworzenia generycznego komponentu listy:

  • Edytor kodu

    Generyczny komponent Select z ograniczeniami

  • Układanie w poziomie

    Ułóż składnię ograniczenia generycznego (constraint) w TypeScript:

  • Edytor kodu

    Generyczny komponent z render prop i filtrowaniem

Przydatne artykuły