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

Projekt Główny: Typowany Dashboard Kosmiczny

5 min czytania
W tej lekcji5

Centrum kontroli misji dostaje dane z całej galaktyki: planety, gwiazdy i asteroidy, każde z innymi polami. Operator filtruje je, dodaje nowe odkrycia i przełącza motyw pulpitu na nocną wachtę. Jeden błędny typ w takim systemie i na ekranie pojawia się "undefined" zamiast temperatury gwiazdy. Czas połączyć całą wiedzę z tego modułu w jeden projekt! Zbudujesz typowany dashboard kosmiczny, który wykorzystuje:

  • Interfejsy i typy - dla danych planet, misji i załogi
  • Typowane komponenty - z pełnym typowaniem props
  • useState z generykami - dla zarządzania stanem
  • Typowane zdarzenia - w formularzach i interakcjach
  • Discriminated unions - dla różnych typów danych
  • Context API z TypeScript - dla globalnego stanu

Opis projektu

Dashboard kosmiczny składa się z kilku sekcji:

  1. Panel główny - wyświetla statystyki misji
  2. Lista obiektów kosmicznych - z filtrowaniem według typu (planety, gwiazdy, asteroidy)
  3. Formularz dodawania - z pełnym typowaniem zdarzeń
  4. Przełącznik motywu - ciemny/jasny z Context API

Moja rada: zacznij od typów, nie od JSX. Gdy mapa danych jest gotowa, komponenty praktycznie piszą się same, bo edytor podpowiada każde pole.

Architektura typów

Pierwszy krok to bazowy interfejs ze wspólnymi polami każdego obiektu oraz trzy warianty, które go rozszerzają słowem extends. Każdy wariant dodaje dyskryminator kind z typem literalnym, dokładnie jak w lekcji o discriminated unions:

1// Bazowy interfejs
2interface SpaceEntity {
3  id: string;
4  name: string;
5  discoveredYear: number;
6}
7
8// Discriminated union - różne typy obiektów
9interface PlanetEntity extends SpaceEntity {
10  kind: "planet";
11  radius: number;
12  habitable: boolean;
13}
14
15interface StarEntity extends SpaceEntity {
16  kind: "star";
17  temperature: number;
18  spectralClass: "O" | "B" | "A" | "F" | "G" | "K" | "M";
19}
20
21interface AsteroidEntity extends SpaceEntity {
22  kind: "asteroid";
23  diameter: number;
24  composition: string;
25}

Pole spectralClass przyjmuje tylko siedem prawdziwych klas widmowych gwiazd, od najgorętszych O do najchłodniejszych M. Teraz łączymy warianty w unię i opisujemy stan dashboardu:

1type CosmicObject = PlanetEntity | StarEntity | AsteroidEntity;
2
3// Stan dashboardu
4interface DashboardState {
5  objects: CosmicObject[];
6  filter: CosmicObject["kind"] | "all";
7  searchQuery: string;
8}

Zapis CosmicObject["kind"] wyciąga z unii wszystkie wartości dyskryminatora, czyli "planet" | "star" | "asteroid". Dopisujemy do tego "all" i filtr zna każdą dozwoloną opcję. Gdy dodasz czwarty wariant, filtr zaktualizuje się sam - nic nie trzeba zmieniać ręcznie.

Generyczny komponent DataTable

Tabela ma wyświetlać dowolne obiekty, więc jest generyczna. Nowym elementem jest operator keyof T, który zwraca unię nazw pól typu T. Dzięki niemu kolumna może wskazać tylko pole, które naprawdę istnieje. T[keyof T] oznacza z kolei typ wartości dowolnego z tych pól.

1// Generyczny komponent tabeli z pełnym typowaniem
2interface Column<T> {
3  key: keyof T;
4  label: string;
5  render?: (value: T[keyof T], item: T) => React.ReactNode;
6}
7
8interface DataTableProps<T extends { id: string }> {
9  data: T[];
10  columns: Column<T>[];
11  onRowClick?: (item: T) => void;
12}

Ograniczenie T extends { id: string } gwarantuje klucz dla każdego wiersza. Opcjonalny render pozwala kolumnie narysować wartość po swojemu, na przykład dodać jednostkę. Sam komponent:

1function DataTable<T extends { id: string }>({
2  data, columns, onRowClick
3}: DataTableProps<T>) {
4  return (
5    <table>
6      <thead>
7        <tr>
8          {columns.map(col => (
9            <th key={String(col.key)}>{col.label}</th>
10          ))}
11        </tr>
12      </thead>
13      <tbody>
14        {data.map(item => (
15          <tr key={item.id} onClick={() => onRowClick?.(item)}>
16            {columns.map(col => (
17              <td key={String(col.key)}>
18                {col.render
19                  ? col.render(item[col.key], item)
20                  : String(item[col.key])}
21              </td>
22            ))}
23          </tr>
24        ))}
25      </tbody>
26    </table>
27  );
28}

String(col.key) jest potrzebne, bo keyof T może teoretycznie zawierać symbole, a key w React oczekuje tekstu lub liczby. onRowClick?.(item) wywoła funkcję tylko wtedy, gdy rodzic ją przekazał.

Typowany formularz dodawania

Formularz też korzysta z discriminated union: każdy rodzaj obiektu ma inne pola, a wartości z pól tekstowych przechowujemy jako string, bo tak zwraca je e.target.value. Zamiana na liczby następuje dopiero przy tworzeniu obiektu.

1type NewObjectForm =
2  | { kind: "planet"; name: string; radius: string; habitable: boolean }
3  | { kind: "star"; name: string; temperature: string; spectralClass: string }
4  | { kind: "asteroid"; name: string; diameter: string; composition: string };

Komponent przyjmuje callback onAdd i trzyma w stanie wybrany rodzaj obiektu oraz nazwę. Handler wysłania korzysta z React.SubmitEvent (w starszych projektach zobaczysz tu przestarzały już React.FormEvent):

1function AddObjectForm({ onAdd }: { onAdd: (obj: CosmicObject) => void }) {
2  const [kind, setKind] = useState<CosmicObject["kind"]>("planet");
3  const [name, setName] = useState("");
4
5  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
6    e.preventDefault();
7    // Tworzy odpowiedni obiekt na podstawie wybranego typu
8    const id = crypto.randomUUID();
9    const year = new Date().getFullYear();
10
11    // ... logika tworzenia obiektu
12  };
13
14  return (
15    <form onSubmit={handleSubmit}>
16      <select
17        value={kind}
18        onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
19          setKind(e.target.value as CosmicObject["kind"])
20        }
21      >
22        <option value="planet">Planeta</option>
23        <option value="star">Gwiazda</option>
24        <option value="asteroid">Asteroida</option>
25      </select>
26      <input value={name} onChange={e => setName(e.target.value)} />
27      <button type="submit">Dodaj</button>
28    </form>
29  );
30}

crypto.randomUUID() to wbudowana funkcja przeglądarki generująca unikalny identyfikator - działa na stronach HTTPS i na localhost. W polu input nie podaliśmy typu zdarzenia, bo przy funkcji wpisanej bezpośrednio w atrybut TypeScript wywnioskuje go sam. Brakującą logikę tworzenia obiektu dopiszesz jako ćwiczenie: switch po kind i assertNever w gałęzi default.

Cały projekt

Poniżej znajdziesz kompletną implementację dashboardu kosmicznego z pełnym typowaniem TypeScript. Przeanalizuj kod, zmodyfikuj go i eksperymentuj z typami! Spróbuj dodać czwarty rodzaj obiektu, na przykład kometę, i obserwuj, ile miejsc wskaże kompilator.

Kod do tej lekcji: App.tsx
1import React, { createContext, useContext, useReducer, useState, useRef } from 'react';
2
3// ===== TYPY DANYCH =====
4interface SpaceEntity {
5  id: string;
6  name: string;
7  discoveredYear: number;
8}
9
10interface PlanetEntity extends SpaceEntity {
11  kind: "planet";
12  radius: number;
13  habitable: boolean;
14}
15
16interface StarEntity extends SpaceEntity {
17  kind: "star";
18  temperature: number;
19  spectralClass: "O" | "B" | "A" | "F" | "G" | "K" | "M";
20}
21
22interface AsteroidEntity extends SpaceEntity {
23  kind: "asteroid";
24  diameter: number;
25  composition: string;
26}
27
28type CosmicObject = PlanetEntity | StarEntity | AsteroidEntity;
29
30// ===== STAN I AKCJE =====
31interface DashboardState {
32  objects: CosmicObject[];
33  filter: CosmicObject["kind"] | "all";
34  searchQuery: string;
35  theme: "dark" | "light";
36}
37
38type DashboardAction =
39  | { type: "ADD_OBJECT"; payload: CosmicObject }
40  | { type: "REMOVE_OBJECT"; payload: string }
41  | { type: "SET_FILTER"; payload: DashboardState["filter"] }
42  | { type: "SET_SEARCH"; payload: string }
43  | { type: "TOGGLE_THEME" };
44
45function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
46  switch (action.type) {
47    case "ADD_OBJECT":
48      return { ...state, objects: [...state.objects, action.payload] };
49    case "REMOVE_OBJECT":
50      return { ...state, objects: state.objects.filter(o => o.id !== action.payload) };
51    case "SET_FILTER":
52      return { ...state, filter: action.payload };
53    case "SET_SEARCH":
54      return { ...state, searchQuery: action.payload };
55    case "TOGGLE_THEME":
56      return { ...state, theme: state.theme === "dark" ? "light" : "dark" };
57    default:
58      return state;
59  }
60}
61
62// ===== CONTEXT =====
63interface DashboardContextType {
64  state: DashboardState;
65  dispatch: React.Dispatch<DashboardAction>;
66}
67
68const DashboardContext = createContext<DashboardContextType | undefined>(undefined);
69
70function useDashboard(): DashboardContextType {
71  const ctx = useContext(DashboardContext);
72  if (!ctx) throw new Error("useDashboard wymaga DashboardProvider");
73  return ctx;
74}
75
76// ===== GENERYCZNY KOMPONENT =====
77interface HasId { id: string; }
78
79function DataList<T extends HasId>({
80  items, renderItem, emptyMsg = "Brak danych"
81}: {
82  items: T[];
83  renderItem: (item: T) => React.ReactNode;
84  emptyMsg?: string;
85}) {
86  if (items.length === 0) return <p className="empty">{emptyMsg}</p>;
87  return (
88    <div className="data-list">
89      {items.map(item => <div key={item.id} className="data-item">{renderItem(item)}</div>)}
90    </div>
91  );
92}
93
94// ===== KOMPONENT KARTY =====
95function ObjectCard({ obj, onRemove }: { obj: CosmicObject; onRemove: (id: string) => void }) {
96  const icons = { planet: "P", star: "S", asteroid: "A" };
97  const colors = { planet: "#22c55e", star: "#f59e0b", asteroid: "#ef4444" };
98
99  const renderDetails = (): React.ReactNode => {
100    switch (obj.kind) {
101      case "planet":
102        return <><p>Promien: {obj.radius} km</p><p>{obj.habitable ? "Zamieszkiwalna" : "Niezamieszkiwalna"}</p></>;
103      case "star":
104        return <><p>Temp: {obj.temperature}K</p><p>Klasa: {obj.spectralClass}</p></>;
105      case "asteroid":
106        return <><p>Srednica: {obj.diameter} km</p><p>Sklad: {obj.composition}</p></>;
107    }
108  };
109
110  return (
111    <div className="object-card" style={{ borderLeftColor: colors[obj.kind] }}>
112      <div className="card-top">
113        <span className="obj-icon" style={{ background: colors[obj.kind] }}>{icons[obj.kind]}</span>
114        <div>
115          <h3>{obj.name}</h3>
116          <span className="year">Odkryto: {obj.discoveredYear}</span>
117        </div>
118        <button className="remove-btn" onClick={() => onRemove(obj.id)}>X</button>
119      </div>
120      {renderDetails()}
121    </div>
122  );
123}
124
125// ===== FORMULARZ =====
126function AddForm() {
127  const { dispatch } = useDashboard();
128  const [kind, setKind] = useState<CosmicObject["kind"]>("planet");
129  const [name, setName] = useState("");
130  const nameRef = useRef<HTMLInputElement>(null);
131
132  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
133    e.preventDefault();
134    if (!name.trim()) return;
135    const base = { id: Date.now().toString(), name, discoveredYear: 2026 };
136
137    let obj: CosmicObject;
138    switch (kind) {
139      case "planet":
140        obj = { ...base, kind: "planet", radius: 6371, habitable: false };
141        break;
142      case "star":
143        obj = { ...base, kind: "star", temperature: 5778, spectralClass: "G" };
144        break;
145      case "asteroid":
146        obj = { ...base, kind: "asteroid", diameter: 100, composition: "skala" };
147        break;
148    }
149    dispatch({ type: "ADD_OBJECT", payload: obj });
150    setName("");
151    nameRef.current?.focus();
152  };
153
154  return (
155    <form onSubmit={handleSubmit} className="add-form">
156      <select value={kind} onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
157        setKind(e.target.value as CosmicObject["kind"])
158      }>
159        <option value="planet">Planeta</option>
160        <option value="star">Gwiazda</option>
161        <option value="asteroid">Asteroida</option>
162      </select>
163      <input ref={nameRef} value={name} onChange={e => setName(e.target.value)} placeholder="Nazwa obiektu" />
164      <button type="submit">Dodaj</button>
165    </form>
166  );
167}
168
169// ===== STATYSTYKI =====
170function Stats() {
171  const { state } = useDashboard();
172  const total = state.objects.length;
173  const planets = state.objects.filter(o => o.kind === "planet").length;
174  const stars = state.objects.filter(o => o.kind === "star").length;
175  const asteroids = state.objects.filter(o => o.kind === "asteroid").length;
176
177  return (
178    <div className="stats">
179      <div className="stat"><span className="stat-num">{total}</span><span>Razem</span></div>
180      <div className="stat"><span className="stat-num" style={{color:"#22c55e"}}>{planets}</span><span>Planety</span></div>
181      <div className="stat"><span className="stat-num" style={{color:"#f59e0b"}}>{stars}</span><span>Gwiazdy</span></div>
182      <div className="stat"><span className="stat-num" style={{color:"#ef4444"}}>{asteroids}</span><span>Asteroidy</span></div>
183    </div>
184  );
185}
186
187// ===== GLOWNA APLIKACJA =====
188const initialState: DashboardState = {
189  objects: [
190    { id: "1", kind: "planet", name: "Kepler-442b", discoveredYear: 2015, radius: 8200, habitable: true },
191    { id: "2", kind: "star", name: "Betelgeuse", discoveredYear: 1603, temperature: 3500, spectralClass: "M" },
192    { id: "3", kind: "asteroid", name: "Apophis", discoveredYear: 2004, diameter: 370, composition: "zelazo, nikiel" },
193    { id: "4", kind: "planet", name: "Proxima b", discoveredYear: 2016, radius: 7100, habitable: true },
194    { id: "5", kind: "star", name: "Sirius", discoveredYear: -200, temperature: 9940, spectralClass: "A" },
195  ],
196  filter: "all",
197  searchQuery: "",
198  theme: "dark"
199};
200
201function Dashboard() {
202  const { state, dispatch } = useDashboard();
203
204  const filtered = state.objects
205    .filter(o => state.filter === "all" || o.kind === state.filter)
206    .filter(o => o.name.toLowerCase().includes(state.searchQuery.toLowerCase()));
207
208  return (
209    <>
210      <header className="header">
211        <h1>Dashboard Kosmiczny</h1>
212        <button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
213          {state.theme === "dark" ? "Jasny" : "Ciemny"}
214        </button>
215      </header>
216
217      <Stats />
218      <AddForm />
219
220      <div className="toolbar">
221        <input
222          className="search"
223          value={state.searchQuery}
224          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
225            dispatch({ type: "SET_SEARCH", payload: e.target.value })
226          }
227          placeholder="Szukaj..."
228        />
229        <div className="filter-btns">
230          {(["all","planet","star","asteroid"] as const).map(f => (
231            <button key={f}
232              className={"fbtn " + (state.filter === f ? "active" : "")}
233              onClick={() => dispatch({ type: "SET_FILTER", payload: f })}
234            >
235              {f === "all" ? "Wszystkie" : f === "planet" ? "Planety" : f === "star" ? "Gwiazdy" : "Asteroidy"}
236            </button>
237          ))}
238        </div>
239      </div>
240
241      <DataList<CosmicObject>
242        items={filtered}
243        renderItem={(obj) => (
244          <ObjectCard obj={obj} onRemove={(id) => dispatch({ type: "REMOVE_OBJECT", payload: id })} />
245        )}
246        emptyMsg="Nie znaleziono obiektow"
247      />
248    </>
249  );
250}
251
252function App() {
253  const [state, dispatch] = useReducer(dashboardReducer, initialState);
254  return (
255    <DashboardContext.Provider value={{ state, dispatch }}>
256      <div className={"app " + state.theme}>
257        <Dashboard />
258      </div>
259    </DashboardContext.Provider>
260  );
261}
262
263export default App;

Po tym projekcie jesteś gotów na typowane aplikacje w kolejnych światach, gdzie TypeScript będzie towarzyszył Ci w Next.js i na serwerze.

Pamiętaj: dobrze otypowany dashboard to centrum kontroli misji, w którym każdy wskaźnik pokazuje dokładnie to, co obiecuje.

Widzisz błąd w tej lekcji?

Przydatne artykuły