Web Development with React
Hands-onIntermediate 40 min· Building real apps · 14 of 21

TypeScript with React

Add types to a React app so mistakes show up in your editor instead of in the browser. Learn types for props, state, events, fetched data and custom hooks by typing a Brew & Bean order screen.

In this tutorial you will create a React + TypeScript project and type the main parts of an app: props, state, event handlers, API data and a custom hook. The example is the Brew & Bean menu and order form.

Prerequisites: React basics (components, props, state, effects) and the lesson *Modern JavaScript for React*. You do not need to know TypeScript already. Node.js must be installed.

Why TypeScript?

TypeScript is JavaScript with types: labels that say what kind of value a variable, parameter or prop holds. The editor checks them while you type and underlines mistakes, such as passing a string where a number is expected, or misspelling a property name. The browser never sees TypeScript; a build step removes the types and produces plain JavaScript.

In React the biggest benefits are: props that cannot be forgotten or misspelled, autocomplete for every field of your API data, and safe refactoring when you rename something. Many companies expect TypeScript for React jobs. Types are checked only before the code runs, so they do not replace validating data from the server at run time.

Create the project

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

The react-ts template gives you .tsx files, a tsconfig, and strict type checking turned on. If the command asks questions in your version of Vite, choose React and TypeScript.

Components now live in .tsx files (TypeScript with JSX), and plain logic in .ts files. Notice tsconfig.json has "strict": true. Keep it on; it is what makes the checking useful. Run npx tsc -b (or npm run build, which runs the same check first) to see all type errors in the project, not just those in the open file.

TypeScript in five minutes

types-basics.ts
// Basic types
const shopName: string = 'Brew & Bean';
const price: number = 120;
const isOpen: boolean = true;
const tags: string[] = ['coffee', 'hot'];

// TypeScript can usually work the type out itself: no label needed
const city = 'Bengaluru'; // inferred as string

// An object shape: a type alias
type MenuItem = {
  id: string;
  name: string;
  category: 'coffee' | 'tea' | 'bakery' | 'food'; // union of allowed values
  price: number;
  description?: string; // ? means optional
};

const espresso: MenuItem = {
  id: 'espresso',
  name: 'Espresso',
  category: 'coffee',
  price: 120,
};

// Function with typed parameters and return type
function withGst(amount: number, rate: number = 5): number {
  return amount + (amount * rate) / 100;
}

Use type (or interface) to name object shapes. A union of string literals such as 'coffee' | 'tea' allows only those exact values. The GST rate here is only an example for the code.

If you write espresso.price = 'cheap', the editor says that a string is not assignable to a number. If you write category: 'pizza', it says that value is not allowed. These errors appear before you ever open the browser.

A routine for typing any component

  1. 1Write the data types first (MenuItem, Order) in src/types.ts.
  2. 2Type the props of the component with a type and annotate the parameter.
  3. 3Type each useState whose first value does not show the real type.
  4. 4Type event handlers using React's event types, or write them inline.
  5. 5Run npm run build and fix every error it lists, without using any.

Typing the data

Start with the shapes that come from your API. Put them in one file and import them where needed. The shape of an order matches the Brew & Bean API: status goes from received to preparing, ready and picked_up, or cancelled.

src/types.ts
export type Category = 'coffee' | 'tea' | 'bakery' | 'food';

export type MenuItem = {
  id: string;
  name: string;
  category: Category;
  price: number;
};

export type OrderStatus =
  | 'received'
  | 'preparing'
  | 'ready'
  | 'picked_up'
  | 'cancelled';

export type OrderLine = {
  item_id: string;
  qty: number;
};

export type Order = {
  id: string;
  customer_name: string;
  phone: string;
  items: OrderLine[];
  total: number;
  status: OrderStatus;
  pickup_time: string;
  created_at: string;
};

Typing props

Describe the props as a type and annotate the destructured parameter. Required props cannot be forgotten; optional ones get ?. Callback props are typed as functions. You do not need React.FC; a plain function with typed props is the common modern style.

src/MenuCard.tsx
import type { MenuItem } from './types';

type MenuCardProps = {
  item: MenuItem;
  soldOut?: boolean;
  onAdd: (itemId: string) => void;
};

function MenuCard({ item, soldOut = false, onAdd }: MenuCardProps) {
  return (
    <article>
      <h3>{item.name}</h3>
      <p>
        {item.category} - ₹{item.price}
      </p>
      <button disabled={soldOut} onClick={() => onAdd(item.id)}>
        {soldOut ? 'Sold out' : 'Add'}
      </button>
    </article>
  );
}

export default MenuCard;

Try <MenuCard item={...} /> without onAdd, or onAdd={5}. The editor reports both mistakes immediately. Children are typed with React.ReactNode if you need them: children: React.ReactNode.

Typing state

useState infers the type from its first value: useState(0) is a number, useState('') is a string. When the starting value does not show the real type (an empty array, or null before data arrives) pass the type in angle brackets, called a generic.

src/MenuPage.tsx
import { useEffect, useState } from 'react';
import MenuCard from './MenuCard';
import type { MenuItem } from './types';

function MenuPage() {
  const [menu, setMenu] = useState<MenuItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [cart, setCart] = useState<Record<string, number>>({});

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const response = await fetch('/menu.json', { signal: controller.signal });
        if (!response.ok) throw new Error('Status ' + response.status);
        const data: MenuItem[] = await response.json();
        setMenu(data);
        setLoading(false);
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') return;
        setError(err instanceof Error ? err.message : 'Unknown error');
        setLoading(false);
      }
    }

    load();
    return () => controller.abort();
  }, []);

  function addToCart(itemId: string) {
    setCart((current) => ({ ...current, [itemId]: (current[itemId] ?? 0) + 1 }));
  }

  if (loading) return <p>Loading...</p>;
  if (error) return <p role="alert">{error}</p>;

  return (
    <section>
      <h1>Menu</h1>
      {menu.map((item) => (
        <MenuCard key={item.id} item={item} onAdd={addToCart} />
      ))}
      <p>Items in cart: {Object.values(cart).reduce((a, b) => a + b, 0)}</p>
    </section>
  );
}

export default MenuPage;

Create public/menu.json as in the useEffect lesson. useState<string | null> says 'a string or null'. Record<string, number> means an object with string keys and number values. In a catch block, err is unknown, so check what it is before using it.

A type annotation on response.json() is a promise, not a proof

const data: MenuItem[] = await response.json() tells TypeScript to trust you. If the server sends a different shape, nothing checks it at run time and the app can crash later. For data you do not control, validate at run time with a library such as Zod, or at least check the important fields. Types protect you from your own mistakes, not from bad data.

Typing events

React exports a type for each kind of event, with the element in angle brackets: React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>, React.MouseEvent<HTMLButtonElement>. Hover over the onChange attribute in your editor and it shows the exact type expected. If you write the handler inline, TypeScript works out the event type for you and you write nothing.

src/OrderForm.tsx
import { useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';

type FormValues = {
  customer_name: string;
  phone: string;
  qty: number;
};

function OrderForm() {
  const [values, setValues] = useState<FormValues>({
    customer_name: '',
    phone: '',
    qty: 1,
  });

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    const { name, value, type } = event.target;
    setValues((previous) => ({
      ...previous,
      [name]: type === 'number' ? Number(value) : value,
    }));
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log('Order', values);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="customer_name">Name</label>
      <input
        id="customer_name"
        name="customer_name"
        value={values.customer_name}
        onChange={handleChange}
      />
      <label htmlFor="phone">Mobile</label>
      <input id="phone" name="phone" value={values.phone} onChange={handleChange} />
      <label htmlFor="qty">Quantity</label>
      <input
        id="qty"
        name="qty"
        type="number"
        min="1"
        value={values.qty}
        onChange={handleChange}
      />
      <button type="submit">Place order</button>
    </form>
  );
}

export default OrderForm;

Types for the event objects come from React. We convert qty to a number because an input's value is always a string.

Typing a custom hook

A generic hook works with many kinds of data. useFetch<T> says: 'I return data of whatever type T the caller chooses'. This is the same hook as in *Custom hooks*, now typed.

src/hooks/useFetch.ts
import { useEffect, useState } from 'react';

type FetchState<T> = {
  data: T | null;
  loading: boolean;
  error: string | null;
};

export function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function load() {
      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error('Status ' + response.status);
        setData((await response.json()) as T);
        setLoading(false);
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') return;
        setError(err instanceof Error ? err.message : 'Unknown error');
        setLoading(false);
      }
    }

    load();
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

Call it as const { data: menu, loading, error } = useFetch<MenuItem[]>('/menu.json'). Now menu is MenuItem[] | null, and TypeScript forces you to handle null before using it.

Reading the errors

Common TypeScript errors in React

Message (shortened)Usually meansFix
Object is possibly 'null'State starts as null and you used it before checkingCheck first: if (!data) return ...; or use data?.name
Type 'string' is not assignable to type 'number'Wrong kind of value, often from an inputConvert with Number(value)
Property 'x' is missing in type ... but required in type ...You forgot a required propPass it, or make it optional with ?
Property 'x' does not exist on type ...Typo, or the type is incompleteFix the name or add the field to the type
Parameter 'x' implicitly has an 'any' typeYou did not say what type a parameter isAdd a type annotation

Avoid `any`

any switches the checking off for that value and quietly spreads. If you are stuck, use unknown (you must check it before use) or ask for help with the real type. Using as to force a type, or // @ts-ignore, hides a problem rather than solving it; use them rarely and with a reason.

How this comes up in interviews: "type vs interface?" (both describe object shapes; interfaces can be extended and merged, types can also express unions and more; either is fine for props, so follow your team's habit) and "What is a generic?" (a type parameter, such as useState<T> or useFetch<T>, so one function works safely with many types).

Prompt · Ask AI to explain a TypeScript error
I am learning React with TypeScript. I get this error in my editor:

[paste the full error message]

Here is the code around it:

---
[paste about 20 lines]
---

Explain in plain English what the error means, why TypeScript is complaining, and show the smallest fix that keeps the types strict. Do not use `any` or `@ts-ignore`.

What to expect: A good answer names the exact mismatch (for example 'null before the data arrives') and fixes it with a check or the right generic, not with any.

Try it: Type the rest of the app

1. Type a Cart component that takes lines: OrderLine[], menu: MenuItem[] and onRemove: (itemId: string) => void, and shows each line's name and total. 2. Write a function orderTotal(lines: OrderLine[], menu: MenuItem[]): number. Deliberately pass the wrong arguments and read the error. 3. Use useFetch<Order>('/orders/123') and show order.status. Try writing order.statuss and see what happens. 4. Add variant: 'primary' | 'ghost' to a Button component, extend the native button props with React.ComponentProps<'button'>, and check that <Button variant="red"> is rejected.

Key takeaways

  1. TypeScript adds types that are checked in your editor and at build time, then removed; it does not check data at run time.
  2. Describe API data once in types.ts with type aliases, optional ? fields and unions of literal strings.
  3. Type props with a type for the destructured parameter; callbacks are function types.
  4. Give useState a generic when the initial value is not enough: useState<MenuItem[]>([]), useState<string | null>(null).
  5. Use React's event types (ChangeEvent<HTMLInputElement>, FormEvent<HTMLFormElement>) and generic hooks like useFetch<T>.
  6. Keep strict on, avoid any, and validate untrusted data at run time.

Check yourself

Finishing the quiz marks the lesson as done.

1. What happens to your TypeScript types when the app is built for the browser?

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

3. const [user, setUser] = useState<User | null>(null). What does TypeScript force you to do before reading user.name?

4. You write const data: MenuItem[] = await response.json() but the server returns a different shape. What does TypeScript do?

5. Which type fits the handler function handle(e: ???) { setName(e.target.value) } for <input onChange={handle} />?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free