TypeScript with React

TypeScript · Lesson 26 of 28 7 min read Updated

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

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

The 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.json sets "jsx": "react-jsx", "moduleResolution": "bundler", "noEmit": true and options such as verbatimModuleSyntax and erasableSyntaxOnly.
  • package.json has "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:

React TSX
// 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:

React TSX
// 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} />
    </>
  );
}
Output
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:

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

React TSX
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 type never[] and nothing could be added.
  • nameRef.current is HTMLInputElement | null, because the element does not exist before the first render; ?. handles that.
  • Event types come from React: ChangeEvent<HTMLInputElement> for inputs and SubmitEvent<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 as onChange, 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:

TypeScriptRun it yourself
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));
Output
[ '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:

React TSX
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 of npm run build).
  • useState([]) without a type. It becomes never[]. Write useState<Item[]>([]).
  • Typing every inline handler by hand. onClick={(e) => ...} already knows e; annotate only separate handler functions.
  • Using as User[] on fetched JSON. It checks nothing. Use a type guard or a validation library.
  • Using any for 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 -b in the build script and your editor do the checking.
  • Type props with a type or interface; use ReactNode for children and ComponentProps<"element"> to reuse HTML props.
  • useState infers types from the starting value; pass a type for empty arrays and null; use useRef<HTMLInputElement>(null) for elements.
  • Model reducer actions as a discriminated union, and validate API data as unknown before 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
React TSX
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?

2. What is the type of useState([]) with no type argument?

3. Which type should the children prop usually have?

4. How can a Button component accept every normal <button> prop?

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