Kurs JavaScript i React · Moduł 12: React i TypeScript
Projekt Główny: Typowany Dashboard Kosmiczny
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:
- Panel główny - wyświetla statystyki misji
- Lista obiektów kosmicznych - z filtrowaniem według typu (planety, gwiazdy, asteroidy)
- Formularz dodawania - z pełnym typowaniem zdarzeń
- 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?