JavaScript and React course Β· Module 12: React and TypeScript
Main Project: Typed Cosmic Dashboard
In this lesson5
Mission control receives data from the whole galaxy: planets, stars and asteroids, each with different fields. The operator filters them, adds new discoveries and switches the console theme for the night watch. One wrong type in such a system and the screen shows "undefined" instead of a star's temperature. Time to combine all the knowledge from this module into one project! You'll build a typed cosmic dashboard that uses:
- Interfaces and types - for planet, mission, and crew data
- Typed components - with full props typing
- useState with generics - for state management
- Typed events - in forms and interactions
- Discriminated unions - for different data types
- Context API with TypeScript - for global state
Project Description
The cosmic dashboard consists of several sections:
- Main panel - displays mission statistics
- Cosmic object list - with filtering by type (planets, stars, asteroids)
- Add form - with full event typing
- Theme toggle - dark/light with Context API
My advice: start with the types, not the JSX. Once the data map is ready, the components practically write themselves, because the editor suggests every field.
Type Architecture
The first step is a base interface with the fields every object shares, plus three variants that extend it with extends. Each variant adds a kind discriminator with a literal type, exactly as in the lesson on discriminated unions:
1// Base interface
2interface SpaceEntity {
3 id: string;
4 name: string;
5 discoveredYear: number;
6}
7
8// Discriminated union - different object types
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}The spectralClass field accepts only the seven real stellar spectral classes, from the hottest O to the coolest M. Now we join the variants into a union and describe the dashboard state:
1type CosmicObject = PlanetEntity | StarEntity | AsteroidEntity;
2
3// Dashboard state
4interface DashboardState {
5 objects: CosmicObject[];
6 filter: CosmicObject["kind"] | "all";
7 searchQuery: string;
8}CosmicObject["kind"] extracts every discriminator value from the union, i.e. "planet" | "star" | "asteroid". We add "all" and the filter knows every allowed option. When you add a fourth variant, the filter updates itself - nothing needs changing by hand.
Generic DataTable Component
The table should display any objects, so it's generic. The new element is the keyof T operator, which returns a union of the field names of type T. Thanks to it, a column can point only at a field that really exists. T[keyof T] in turn means the type of the value of any of those fields.
1// Generic table component with full typing
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}The constraint T extends { id: string } guarantees a key for every row. The optional render lets a column draw its value in its own way, for example with a unit. The component itself:
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) is needed because keyof T can in theory include symbols, while a React key expects a string or number. onRowClick?.(item) calls the function only if the parent passed it.
Typed Add Form
The form also uses a discriminated union: each object kind has different fields, and values from text inputs are stored as string, because that's what e.target.value returns. Conversion to numbers happens only when the object is created.
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 };The component takes an onAdd callback and keeps the selected object kind and the name in state. The submit handler uses React.SubmitEvent (in older projects you'll see the now deprecated React.FormEvent here):
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 // Creates the appropriate object based on the selected type
8 const id = crypto.randomUUID();
9 const year = new Date().getFullYear();
10
11 // ... object creation logic
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">Planet</option>
23 <option value="star">Star</option>
24 <option value="asteroid">Asteroid</option>
25 </select>
26 <input value={name} onChange={e => setName(e.target.value)} />
27 <button type="submit">Add</button>
28 </form>
29 );
30}crypto.randomUUID() is a built-in browser function that generates a unique identifier - it works on HTTPS pages and on localhost. We didn't give the input an event type, because for a function written directly in the attribute TypeScript infers it. You'll write the missing object creation logic as an exercise: a switch on kind and assertNever in the default branch.
Complete Project
Below you'll find the complete cosmic dashboard implementation with full TypeScript typing. Analyze the code, modify it, and experiment with the types! Try adding a fourth object kind, such as a comet, and watch how many places the compiler points out.
Code for this lesson: App.tsx
1import React, { createContext, useContext, useReducer, useState, useRef } from 'react';
2
3// ===== DATA TYPES =====
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// ===== STATE AND ACTIONS =====
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 requires DashboardProvider");
73 return ctx;
74}
75
76// ===== GENERIC COMPONENT =====
77interface HasId { id: string; }
78
79function DataList<T extends HasId>({
80 items, renderItem, emptyMsg = "No data"
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// ===== CARD COMPONENT =====
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>Radius: {obj.radius} km</p><p>{obj.habitable ? "Habitable" : "Uninhabitable"}</p></>;
103 case "star":
104 return <><p>Temp: {obj.temperature}K</p><p>Class: {obj.spectralClass}</p></>;
105 case "asteroid":
106 return <><p>Diameter: {obj.diameter} km</p><p>Composition: {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">Discovered: {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// ===== FORM =====
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: "rock" };
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">Planet</option>
160 <option value="star">Star</option>
161 <option value="asteroid">Asteroid</option>
162 </select>
163 <input ref={nameRef} value={name} onChange={e => setName(e.target.value)} placeholder="Object name" />
164 <button type="submit">Add</button>
165 </form>
166 );
167}
168
169// ===== STATISTICS =====
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>Total</span></div>
180 <div className="stat"><span className="stat-num" style={{color:"#22c55e"}}>{planets}</span><span>Planets</span></div>
181 <div className="stat"><span className="stat-num" style={{color:"#f59e0b"}}>{stars}</span><span>Stars</span></div>
182 <div className="stat"><span className="stat-num" style={{color:"#ef4444"}}>{asteroids}</span><span>Asteroids</span></div>
183 </div>
184 );
185}
186
187// ===== MAIN APP =====
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: "iron, nickel" },
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>Cosmic Dashboard</h1>
212 <button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
213 {state.theme === "dark" ? "Light" : "Dark"}
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="Search..."
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" ? "All" : f === "planet" ? "Planets" : f === "star" ? "Stars" : "Asteroids"}
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="No objects found"
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;After this project you're ready for typed applications in the next worlds, where TypeScript will accompany you in Next.js and on the server.
Remember: a well-typed dashboard is a mission control center where every gauge shows exactly what it promises.
Spotted a mistake in this lesson?