React with TypeScript

React · Lesson 24 of 29 6 min read Updated

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:

Terminal
npm create vite@latest shop-ts -- --template react-ts
cd shop-ts
npm install
npm run dev

Component 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:

React TSXTry it yourself
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:

React TSX
import { ProductCard } from "./ProductCard";

export const sale = <ProductCard title="Silk dupatta" price={2400} tone="red" />;
export const plain = <ProductCard tone="info" />;
Output
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, price

Each 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:

React TSXTry it yourself
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 as onChange={(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 type imports 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:

React TSXTry it yourself
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:

React TSX
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-checked

Common mistakes

  • Using any to silence errors. It turns off checking for that value. Fix the type or use unknown and narrow it.
  • Forgetting the type for empty arrays. useState([]) infers never[], so nothing can be added. Write useState<Item[]>([]).
  • Thinking Vite checks types. The dev server runs code with type errors. Watch your editor and run tsc -b or npm 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 .tsx files.
  • Type props with a type or interface; use ReactNode for children and unions for variants.
  • useState infers types; add <Type> for null starts and empty arrays.
  • Use ChangeEvent, SubmitEvent and MouseEvent from react for named handlers, and useRef<HTMLInputElement>(null) for DOM refs.
  • Discriminated unions make reducer actions type-safe.
  • Vite and this site's editor strip types without checking; run tsc or 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
React TSX
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?

2. Why write useState<Item[]>([]) instead of useState([])?

3. Does the Vite dev server stop when your code has a type error?

4. Which type fits a named handler for a text input's onChange?

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