React with TypeScript
TypeScript adds types to JavaScript: you describe the shape of your data, and the compiler checks your code before it runs. In a React app this catches a whole family of bugs: a missing prop, a misspelt variant name, a number passed where a string was expected. Editors also use the types to offer accurate autocomplete, which makes large codebases much easier to work in.
In this lesson you will set up a TypeScript React project, type props and children, state, events, refs and reducers, and learn to read the compiler's error messages. You should know basic TypeScript syntax such as type, unions ("a" | "b") and optional properties (name?: string).
Setting up
Create a project with the TypeScript template:
npm create vite@latest shop-ts -- --template react-ts
cd shop-ts
npm install
npm run devComponent files now end in .tsx (TypeScript with JSX), and plain logic files in .ts. The template installs TypeScript 7, the fast native rewrite of the compiler released in 2026, and its build script runs tsc -b && vite build, so a type error stops the production build.
Vite itself does not check types while you develop; it only removes them so the code runs quickly. Your editor shows errors as you type, and npm run build (or npx tsc -b) checks the whole project. The Try-it editor on this site works like Vite: tsx examples run with their types removed, without type checking.
Typing props
Describe a component's props with a type (an interface works too), then annotate the destructured parameter:
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
type CardProps = {
title: string;
price: number;
tone?: "info" | "sale"; // optional, and only these two values
children?: ReactNode; // anything React can render
};
function ProductCard({ title, price, tone = "info", children }: CardProps) {
const border = tone === "sale" ? "#b91c1c" : "#087ea4";
return (
<article style={{ border: `2px solid ${border}`, padding: 10, marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<p>₹{price.toLocaleString("en-IN")}</p>
{children}
</article>
);
}
function Shop() {
return (
<>
<ProductCard title="Kanjeevaram saree" price={18500} tone="sale">
<p>Festive offer: 15% off</p>
</ProductCard>
<ProductCard title="Cotton kurta" price={1299} />
</>
);
}
createRoot(document.getElementById("root")!).render(<Shop />);ReactNode is the type for anything that can appear between tags: text, numbers, elements, arrays, null. The ! after getElementById("root") tells TypeScript "I know this element exists"; without it, TypeScript points out that the function may return null.
Here is what the compiler (npx tsc -b) says when another file passes a wrong variant and forgets the required props:
import { ProductCard } from "./ProductCard";
export const sale = <ProductCard title="Silk dupatta" price={2400} tone="red" />;
export const plain = <ProductCard tone="info" />;src/Shop.tsx(3,68): error TS2322: Type '"red"' is not assignable to type '"info" | "sale" | undefined'.
src/Shop.tsx(4,23): error TS2739: Type '{ tone: "info"; }' is missing the following properties from type 'CardProps': title, priceEach error gives the file, the line and column, an error code and a plain-language message.
The union type "info" | "sale" turns a silent styling bug into an error that points at the exact line.
State, events and refs
useState usually infers its type from the initial value: useState(0) is a number, useState("") a string. Give an explicit type when the initial value does not show the full picture, such as null now and an object later, or an empty array:
import { useState, useRef } from "react";
import type { ChangeEvent, SubmitEvent } from "react";
import { createRoot } from "react-dom/client";
type Expense = { id: number; title: string; amount: number };
function ExpenseForm() {
const [title, setTitle] = useState(""); // inferred: string
const [amount, setAmount] = useState(0); // inferred: number
const [expenses, setExpenses] = useState<Expense[]>([]); // explicit: starts empty
const [lastAdded, setLastAdded] = useState<Expense | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
function handleTitle(e: ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
}
function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const item: Expense = { id: Date.now(), title, amount };
setExpenses([...expenses, item]);
setLastAdded(item);
setTitle("");
setAmount(0);
titleRef.current?.focus();
}
const total = expenses.reduce((sum, x) => sum + x.amount, 0);
return (
<form onSubmit={handleSubmit}>
<input ref={titleRef} value={title} onChange={handleTitle} placeholder="Expense" />
<input type="number" value={amount} onChange={(e) => setAmount(Number(e.target.value))} />
<button>Add</button>
<p>{expenses.length} expenses, total ₹{total}</p>
{lastAdded && <p>Last: {lastAdded.title} (₹{lastAdded.amount})</p>}
</form>
);
}
createRoot(document.getElementById("root")!).render(<ExpenseForm />);- Event types come from
react:ChangeEvent<HTMLInputElement>for inputs,SubmitEvent<HTMLFormElement>for form submit,MouseEvent<HTMLButtonElement>for clicks. For inline handlers such asonChange={(e) => ...}, TypeScript infers the type, so you only write it for named handler functions. - DOM refs are typed with the element type and start as
null:useRef<HTMLInputElement>(null). Use optional chaining (titleRef.current?.focus()) because the ref is empty until the element exists. import typeimports only types; they disappear completely from the compiled JavaScript.
In older code you will often see React.FormEvent<HTMLFormElement> for submit handlers. The current React type definitions mark FormEvent as deprecated, because no such DOM event exists; prefer SubmitEvent or ChangeEvent.
Typing reducers and context
Reducers benefit most from TypeScript. Describe the actions as a discriminated union: each action type has a type field with a different literal value. Inside each case, TypeScript knows exactly which fields that action has:
import { useReducer } from "react";
import { createRoot } from "react-dom/client";
type State = { balance: number; log: string[] };
type Action =
| { type: "recharged"; amount: number }
| { type: "paid"; amount: number; to: string }
| { type: "reset" };
function walletReducer(state: State, action: Action): State {
switch (action.type) {
case "recharged":
return { balance: state.balance + action.amount, log: [...state.log, `+₹${action.amount}`] };
case "paid":
return { balance: state.balance - action.amount, log: [...state.log, `-₹${action.amount} to ${action.to}`] };
case "reset":
return { balance: 0, log: [] };
}
}
function Wallet() {
const [state, dispatch] = useReducer(walletReducer, { balance: 0, log: [] });
return (
<div>
<h3>Metro card: ₹{state.balance}</h3>
<button onClick={() => dispatch({ type: "recharged", amount: 200 })}>Recharge ₹200</button>{" "}
<button onClick={() => dispatch({ type: "paid", amount: 35, to: "Kochi Metro" })}>Pay fare</button>{" "}
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
<ul>{state.log.map((line, i) => <li key={i}>{line}</li>)}</ul>
</div>
);
}
createRoot(document.getElementById("root")!).render(<Wallet />);Typing action.to inside the "recharged" case would be an error, and dispatching { type: "paid", amount: 35 } without to would be caught too. For context, pass the type to createContext: createContext<User | null>(null).
Extending HTML elements and ref props
To build a wrapper around a native element, reuse React's built-in prop types with ComponentProps. In React 19, ref is part of those props, so a wrapper can forward it simply by spreading:
import type { ComponentProps } from "react";
type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" };
export function Button({ variant = "primary", className = "", ...rest }: ButtonProps) {
return <button className={`btn btn-${variant} ${className}`} {...rest} />;
}
// <Button type="submit" disabled ref={saveRef}>Save</Button> is fully type-checkedCommon mistakes
- Using
anyto silence errors. It turns off checking for that value. Fix the type or useunknownand narrow it. - Forgetting the type for empty arrays.
useState([])infersnever[], so nothing can be added. WriteuseState<Item[]>([]). - Thinking Vite checks types. The dev server runs code with type errors. Watch your editor and run
tsc -bornpm run build. - Typing every inline handler. Let TypeScript infer types for inline callbacks; annotate named handlers and props.
Key points
- Create a project with
--template react-ts; components use.tsxfiles. - Type props with a
typeorinterface; useReactNodeforchildrenand unions for variants. useStateinfers types; add<Type>fornullstarts and empty arrays.- Use
ChangeEvent,SubmitEventandMouseEventfromreactfor named handlers, anduseRef<HTMLInputElement>(null)for DOM refs. - Discriminated unions make reducer actions type-safe.
- Vite and this site's editor strip types without checking; run
tscor the build to type-check.
Exercise
Write a typed StudentRow component with props name: string, marks: number, status: "pass" | "fail" and an optional onSelect callback that receives the name. Render three rows from a typed array Student[], and log the selected name when a row's button is clicked. Then try passing status="absent" and note the error your editor shows.
Show solution
import { createRoot } from "react-dom/client";
type Student = { id: number; name: string; marks: number; status: "pass" | "fail" };
type StudentRowProps = Omit<Student, "id"> & { onSelect?: (name: string) => void };
function StudentRow({ name, marks, status, onSelect }: StudentRowProps) {
return (
<li>
{name}: {marks} ({status}){" "}
{onSelect && <button onClick={() => onSelect(name)}>Select</button>}
</li>
);
}
const students: Student[] = [
{ id: 1, name: "Ananya", marks: 88, status: "pass" },
{ id: 2, name: "Kabir", marks: 29, status: "fail" },
{ id: 3, name: "Isha", marks: 74, status: "pass" },
];
createRoot(document.getElementById("root")!).render(
<ul>
{students.map(({ id, ...s }) => (
<StudentRow key={id} {...s} onSelect={(name) => console.log(`Selected ${name}`)} />
))}
</ul>
);Omit<Student, "id"> reuses the data type for the props. Passing status="absent" gives error TS2322, because "absent" is not one of the allowed values.
Quick Quiz
1. Which type should you use for a children prop that can hold any renderable content?
ReactNode covers text, numbers, elements, arrays and null.
2. Why write useState<Item[]>([]) instead of useState([])?
Without the type argument TypeScript infers never[], so you cannot add items.
3. Does the Vite dev server stop when your code has a type error?
Vite strips types for speed; type checking happens in the editor and in tsc -b during the build.
4. Which type fits a named handler for a text input's onChange?
Change handlers on inputs receive a ChangeEvent whose target is the input.
Learn React 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
