JavaScript and React course Β· Module 12: React and TypeScript

Main Project: Typed Cosmic Dashboard

5 min read
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:

  1. Main panel - displays mission statistics
  2. Cosmic object list - with filtering by type (planets, stars, asteroids)
  3. Add form - with full event typing
  4. 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?

Useful articles