TypeScript with React
React and TypeScript are a very popular pair. Types catch wrong props before the page loads, editors autocomplete every prop of every component, and refactoring a large app becomes much less scary. React's documentation has a TypeScript guide, and the standard Vite starter has a TypeScript template.
In this lesson you will create a React project with TypeScript, type component props and children, type state, refs and events, use a reducer with a discriminated union, and load API data safely. You should know the basics of React components and hooks; if you do not, learn them first and come back. React code is written in .tsx files, which are TypeScript files that may contain JSX.
Creating a project
Create React App is deprecated, so use Vite (or a framework such as Next.js):
npm create vite@latest school-portal -- --template react-ts
cd school-portal
npm install
npm run devThe template installs React 19, TypeScript 7 and the React type packages @types/react and @types/react-dom (React itself does not ship types). Look at two files it creates:
tsconfig.app.jsonsets"jsx": "react-jsx","moduleResolution": "bundler","noEmit": trueand options such asverbatimModuleSyntaxanderasableSyntaxOnly.package.jsonhas"build": "tsc -b && vite build".
That build script matters: Vite removes types but never checks them, so the dev server shows your page even with type errors. The tsc -b step checks the types before every production build, and your editor shows errors while you work.
Typing props
Describe a component's props with a type or interface, and annotate the destructured parameter. Optional props get ?, and you can give them defaults right in the destructuring:
// StudentCard.tsx
type StudentCardProps = {
name: string;
marks: number;
city?: string;
onSelect: (name: string) => void;
};
export function StudentCard({ name, marks, city = "Not given", onSelect }: StudentCardProps) {
const grade = marks >= 80 ? "A" : marks >= 60 ? "B" : "C";
return (
<button type="button" onClick={() => onSelect(name)}>
{name} ({city}): {marks} marks, grade {grade}
</button>
);
}Now every use of <StudentCard> is checked. In this App.tsx, the second card passes marks as text and the third forgets the required callback:
// App.tsx
import { StudentCard } from "./StudentCard.tsx";
export function App() {
return (
<>
<StudentCard name="Isha" marks={91} city="Kochi" onSelect={(n) => console.log(n)} />
<StudentCard name="Rohan" marks="74" onSelect={(n) => console.log(n)} />
<StudentCard name="Priya" marks={88} />
</>
);
}App.tsx(7,33): error TS2322: Type 'string' is not assignable to type 'number'.
App.tsx(8,8): error TS2741: Property 'onSelect' is missing in type '{ name: string; marks: number; }' but required in type 'StudentCardProps'.Notice that (n) => console.log(n) needs no annotation: n is known to be a string from the prop type, through contextual typing.
Children and HTML props
To accept nested content, add a children prop of type ReactNode, which covers text, elements, arrays, null and more. To build a component that wraps a normal HTML element, reuse that element's props with ComponentProps<"button"> instead of listing them all yourself:
import type { ComponentProps, ReactNode } from "react";
type CardProps = {
title: string;
children: ReactNode;
};
export function Card({ title, children }: CardProps) {
return (
<section className="card">
<h2>{title}</h2>
{children}
</section>
);
}
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "danger";
};
export function Button({ variant = "primary", className, ...rest }: ButtonProps) {
return <button className={`btn btn-${variant} ${className ?? ""}`} {...rest} />;
}<Button variant="danger" onClick={...} disabled>Dismiss</Button> now accepts every normal button prop plus variant, and variant="warning" is an error. In React 19, ref is an ordinary prop for function components, so ComponentProps<"button"> includes it too and the older forwardRef wrapper is no longer needed.
State, refs and events
useState infers its type from the starting value. When the starting value does not show the full type, such as an empty array or null, pass the type in angle brackets. For refs to DOM elements, pass the element type and start with null:
import { useRef, useState, type ChangeEvent, type SubmitEvent } from "react";
type Payment = { name: string; amount: number };
export function FeeForm() {
const [name, setName] = useState(""); // string
const [amount, setAmount] = useState(0); // number
const [paid, setPaid] = useState<Payment[]>([]); // needs the type
const nameRef = useRef<HTMLInputElement>(null);
function handleName(e: ChangeEvent<HTMLInputElement>) {
setName(e.target.value);
}
function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (name.trim() === "" || amount <= 0) {
nameRef.current?.focus();
return;
}
setPaid([...paid, { name: name.trim(), amount }]);
setName("");
setAmount(0);
}
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} value={name} onChange={handleName} placeholder="Student name" />
<input type="number" value={amount} onChange={(e) => setAmount(e.target.valueAsNumber || 0)} />
<button>Record payment</button>
<ul>
{paid.map((p, i) => (
<li key={i}>{p.name}: ₹{p.amount}</li>
))}
</ul>
</form>
);
}- Without
<Payment[]>,useState([])would have the typenever[]and nothing could be added. nameRef.currentisHTMLInputElement | null, because the element does not exist before the first render;?.handles that.- Event types come from React:
ChangeEvent<HTMLInputElement>for inputs andSubmitEvent<HTMLFormElement>for forms. Inline handlers such as(e) => setAmount(...)get their types automatically. A quick way to find any event type is to hover over the prop, such asonChange, in your editor.
Reducers with discriminated unions
For state with several kinds of updates, useReducer and a discriminated union of actions work beautifully together. The reducer is a plain function, so you can run and test it without React:
type CartItem = { id: string; name: string; price: number; qty: number };
type CartAction =
| { type: "add"; item: Omit<CartItem, "qty"> }
| { type: "remove"; id: string }
| { type: "clear" };
function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case "add": {
const existing = state.find((i) => i.id === action.item.id);
if (existing) {
return state.map((i) => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i));
}
return [...state, { ...action.item, qty: 1 }];
}
case "remove":
return state.filter((i) => i.id !== action.id);
case "clear":
return [];
}
}
const dosa = { id: "dosa", name: "Masala dosa", price: 90 };
const coffee = { id: "coffee", name: "Filter coffee", price: 30 };
let cart: CartItem[] = [];
cart = cartReducer(cart, { type: "add", item: dosa });
cart = cartReducer(cart, { type: "add", item: coffee });
cart = cartReducer(cart, { type: "add", item: dosa });
console.log(cart.map((i) => `${i.name} x${i.qty}`));
cart = cartReducer(cart, { type: "remove", id: "coffee" });
console.log(cart.reduce((sum, i) => sum + i.price * i.qty, 0));[ 'Masala dosa x2', 'Filter coffee x1' ] 180
In a component, const [items, dispatch] = useReducer(cartReducer, []) infers everything from the reducer. Calling dispatch({ type: "remove" }) without an id, or dispatch({ type: "empty" }), is a compile error, so every action in the app has the right shape.
Loading data safely
Data from an API is unknown until you check it, exactly as on the server. Inside useEffect, fetch the data, validate it with a type guard, then store it:
import { useEffect, useState } from "react";
type User = { id: number; name: string; email: string };
function isUser(value: unknown): value is User {
return (
typeof value === "object" && value !== null &&
"id" in value && typeof value.id === "number" &&
"name" in value && typeof value.name === "string" &&
"email" in value && typeof value.email === "string"
);
}
export function Students() {
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
async function load() {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const data: unknown = await res.json();
if (!ignore) setUsers(Array.isArray(data) ? data.filter(isUser) : []);
} catch (err) {
if (!ignore) setError(err instanceof Error ? err.message : "Unknown error");
}
}
load();
return () => {
ignore = true;
};
}, []);
if (error) return <p>Could not load: {error}</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}The ignore flag prevents an old request from overwriting newer state if the component unmounts. In bigger apps, data libraries such as TanStack Query, or framework features in Next.js, handle loading and caching for you, but the rule stays the same: validate outside data before trusting its type.
Common mistakes
- Expecting Vite to report type errors. It only strips types. Watch your editor and run
tsc -b(part ofnpm run build). useState([])without a type. It becomesnever[]. WriteuseState<Item[]>([]).- Typing every inline handler by hand.
onClick={(e) => ...}already knowse; annotate only separate handler functions. - Using
as User[]on fetched JSON. It checks nothing. Use a type guard or a validation library. - Using
anyfor props. You lose the main benefit of TypeScript in React: checked component usage.
Key points
- Start React projects with
npm create vite@latest -- --template react-ts; React's types come from@types/react. - Vite strips types without checking;
tsc -bin the build script and your editor do the checking. - Type props with a type or interface; use
ReactNodefor children andComponentProps<"element">to reuse HTML props. useStateinfers types from the starting value; pass a type for empty arrays andnull; useuseRef<HTMLInputElement>(null)for elements.- Model reducer actions as a discriminated union, and validate API data as
unknownbefore storing it.
Exercise
Write a TrainList component for a booking app. Its props are trains (an array of { number: number; name: string; seatsLeft: number }) and onBook (a function that receives the train number). Show each train in a list with a "Book" button that is disabled when seatsLeft is 0. Then write the type for its props and show how a parent would use it.
Show solution
type Train = { number: number; name: string; seatsLeft: number };
type TrainListProps = {
trains: Train[];
onBook: (trainNumber: number) => void;
};
export function TrainList({ trains, onBook }: TrainListProps) {
return (
<ul>
{trains.map((t) => (
<li key={t.number}>
{t.number} {t.name}: {t.seatsLeft} seats
<button type="button" disabled={t.seatsLeft === 0} onClick={() => onBook(t.number)}>
Book
</button>
</li>
))}
</ul>
);
}
// In a parent component:
// <TrainList
// trains={[{ number: 12951, name: "Rajdhani", seatsLeft: 4 }, { number: 12015, name: "Shatabdi", seatsLeft: 0 }]}
// onBook={(n) => console.log("Booking", n)}
// />The props type documents exactly what the component needs, and onBook receives a number, so the parent's callback is checked too.
Quick Quiz
1. Which tool checks the types in a Vite React project?
Vite only removes types; type checking is done by the TypeScript compiler.
2. What is the type of useState([]) with no type argument?
An empty array literal gives never[]; pass the item type, as in useState<Payment[]>([]).
3. Which type should the children prop usually have?
ReactNode covers text, elements, arrays, null and other renderable values.
4. How can a Button component accept every normal <button> prop?
ComponentProps<"button"> reuses React's types for the native button element.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
