React with TypeScript

TypeScript adds static types to JavaScript, and when you pair it with React you get autocomplete for props, compile-time errors when you pass the wrong data to a component, and self-documenting code that tells you exactly what a hook returns or what an event handler receives. Most production React codebases today are written in TypeScript, usually using the .tsx file extension for files that contain JSX. This lesson assumes you already know function components and hooks — here we layer TypeScript’s type system on top of that mental model rather than re-teaching React itself.

Overview / How it works

TypeScript is a superset of JavaScript: every valid JavaScript file is also valid TypeScript, and the extra syntax you add (interfaces, generics, type annotations) exists only to help the compiler catch mistakes before your code ever runs. When you build a React app, a tool like Vite, Next.js, or the TypeScript compiler (tsc) reads your .tsx files, checks that every prop, state value, and event handler is used consistently with its declared type, and then strips the types out entirely, emitting plain JavaScript. This is important to internalize: types are a compile-time and editor-time tool only. At runtime, React’s render → reconcile → commit cycle, its rules about re-rendering when state changes, and its rule that hooks must run in the same order on every render are completely unaffected by TypeScript. TypeScript does not make React faster or change how the Virtual DOM works — it only catches a category of bugs (wrong prop names, wrong prop types, calling a function that might be undefined) before they become runtime errors.

To write JSX in TypeScript, your build tool needs the jsx compiler option set to react-jsx (the modern setting, matching the automatic JSX runtime used since React 17), and your project needs the @types/react and @types/react-dom packages installed so the compiler knows the shape of React’s own APIs. Tools like create-vite‘s React + TypeScript template configure all of this for you.

TypeScript can often infer types without you writing them explicitly. useState(0) infers number automatically because the initial value is a number, so setCount('five') would immediately be flagged as an error. Inference breaks down in a few common situations: when the initial state is null or an empty array, when a value can be one of several types (a union), or when a function parameter comes from outside your code (like a DOM event). In those cases you provide the type explicitly using a generic, like useState<User | null>(null).

Syntax

The general shapes you will use constantly are: typing props with an interface, typing state with a generic on useState, and typing event handler parameters using React’s built-in event types.

interface ComponentProps {
  propName: PropType;
  optionalProp?: PropType;
}

function ComponentName({ propName, optionalProp }: ComponentProps) {
  // component body
}

const [state, setState] = useState<StateType>(initialValue);

function handleEvent(e: React.EventType<HTMLElementType>) {
  // handler body
}
  • interface ComponentProps — declares the shape of the props object; use ? to mark a prop optional.
  • useState<StateType> — the generic tells TypeScript what type the state value (and the argument to the setter) must be; required whenever the initial value alone doesn’t reveal the full type, such as null starting values or arrays.
  • React.EventType<HTMLElementType> — React ships specific event types (MouseEvent, ChangeEvent, FormEvent, KeyboardEvent) parameterized by the DOM element the handler is attached to.

Common event and value types you’ll reach for:

Situation Type
Button click React.MouseEvent<HTMLButtonElement>
Text input change React.ChangeEvent<HTMLInputElement>
Form submit React.FormEvent<HTMLFormElement>
Key press React.KeyboardEvent<HTMLInputElement>
DOM node ref useRef<HTMLInputElement>(null)
children prop React.ReactNode

Examples

Example 1: Typed props with an interface

interface GreetingProps {
  name: string;
  age?: number;
}

function Greeting({ name, age }: GreetingProps) {
  return (
    <div>
      <p>Hello, {name}!</p>
      {age !== undefined && <p>You are {age} years old.</p>}
    </div>
  );
}

export default Greeting;

This renders a paragraph greeting the given name, and a second paragraph showing age only when it was passed. The GreetingProps interface makes name required and age optional. If a caller writes <Greeting age={7} /> without name, or passes age="seven" as a string, TypeScript flags it as an error before the app ever runs.

import { createRoot } from "react-dom/client";
import Greeting from "./Greeting";

createRoot(document.getElementById("root")!).render(<Greeting name="Ava" age={7} />);

This mounts the app using the React 18+ root API. The ! after getElementById("root") is a TypeScript non-null assertion telling the compiler “trust me, this element exists,” since getElementById‘s return type is HTMLElement | null.

Example 2: Typed state and a typed event handler

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState<number>(0);

  function handleIncrement(e: React.MouseEvent<HTMLButtonElement>) {
    setCount((prev) => prev + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

export default Counter;

Renders “Count: 0” and an Increment button; each click bumps the displayed count by one. Here useState<number>(0) is technically redundant since TypeScript would infer number from the initial value 0 anyway, but writing it explicitly is a useful habit once state gets more complex. The handler parameter is typed as React.MouseEvent<HTMLButtonElement> so e.currentTarget is correctly typed as an HTMLButtonElement if you needed to read from it.

Example 3: Typed fetch data, useRef, and useEffect

import { useEffect, useRef, useState } from "react";

interface User {
  id: number;
  name: string;
  email: string;
}

function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchUsers() {
      const res = await fetch("https://jsonplaceholder.typicode.com/users");
      const data: User[] = await res.json();
      if (!cancelled) {
        setUsers(data);
        setLoading(false);
      }
    }

    fetchUsers();
    return () => {
      cancelled = true;
    };
  }, []);

  function focusInput() {
    inputRef.current?.focus();
  }

  if (loading) return <p>Loading...</p>;

  return (
    <div>
      <input ref={inputRef} placeholder="Search users" />
      <button onClick={focusInput}>Focus search</button>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name} ({user.email})</li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

On mount this shows “Loading…”, fetches a list of users, then renders a search input, a Focus button, and a bulleted list of “name (email)” entries once the fetch resolves. User[] types the fetched array so every item in the list has guaranteed id, name, and email fields, and useRef<HTMLInputElement>(null) types the ref so inputRef.current is either null or an actual input element, which is why inputRef.current?.focus() uses optional chaining — the ref is null until React attaches it after the first render.

Under the hood

On mount, TypeScript’s role has already ended by the time your code runs in the browser — the build step already compiled .tsx to plain JavaScript and stripped every type annotation, interface, and generic. React then renders the component function, builds its Virtual DOM tree, and commits real DOM nodes exactly as it would for a JavaScript-only app. On a state update (like setCount or setUsers above), React schedules a re-render, calls the component function again, diffs the new Virtual DOM against the previous one, and patches only what changed — again, identical to plain React, because types never exist at runtime. What TypeScript changes is entirely upstream: your editor shows red squiggles the moment you pass the wrong prop type, autocompletes prop names and hook return values as you type, and the build fails fast if a type error slips through, long before a user could see a broken UI.

Common Mistakes

Mistake 1: Letting state be inferred as null forever

function Profile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then((data) => setUser(data));
  }, []);

  return <p>{user.name}</p>; // Error: 'user' is possibly 'null'
}

Without a generic, useState(null) infers the state’s type as null forever, so TypeScript has no idea user will ever hold a User object, and user.name is flagged as an error. Give the state an explicit union type and guard the access with optional chaining:

interface User {
  name: string;
}

function Profile() {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser().then((data: User) => setUser(data));
  }, []);

  return <p>{user?.name}</p>;
}

Mistake 2: Typing event handlers as any

function SearchBox() {
  const [query, setQuery] = useState("");

  function handleChange(e: any) {
    setQuery(e.target.value);
  }

  return <input value={query} onChange={handleChange} />;
}

Typing the event as any disables type checking for everything inside the handler — a typo like e.taget.value would compile silently and crash at runtime. Use React’s specific event type instead:

function SearchBox() {
  const [query, setQuery] = useState("");

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
  }

  return <input value={query} onChange={handleChange} />;
}

Best Practices

  • Enable strict mode in tsconfig.json — it catches implicit any, unchecked null/undefined access, and other bugs that non-strict mode lets through silently.
  • Prefer letting TypeScript infer types from initial values (useState(0)) and only add an explicit generic when inference isn’t enough (null initial values, unions, empty arrays).
  • Avoid any entirely; if a type is genuinely unknown, use unknown and narrow it before use.
  • Type children as React.ReactNode, not JSX.Element, since ReactNode also covers strings, numbers, arrays, and null.
  • Skip React.FC for typing components; typing the props parameter directly (function Card({ title }: CardProps)) is simpler and avoids React.FC‘s historical quirks around implicit children.
  • Put shared interfaces (like API response shapes) in a dedicated types.ts file so multiple components can import the same definition instead of redefining it.
  • Always type useRef with the DOM element it will hold, and initialize it with null (useRef<HTMLInputElement>(null)) so React can safely assign it after mount.

Practice Exercises

  • Create a Product interface with id, title, and price fields, then write a ProductCard component that accepts a single product: Product prop and renders its title and price.
  • Write a Toggle component that uses useState<boolean>(false) to track an on/off state, with a button whose click handler is correctly typed as React.MouseEvent<HTMLButtonElement>, and that displays “On” or “Off” accordingly.
  • Write a custom hook useLocalStorage<T>(key: string, initialValue: T) that returns a typed [T, (value: T) => void] tuple, reading from and writing to localStorage under the given key.

Summary

  • TypeScript adds compile-time type checking on top of React; it has no effect on React’s runtime render, reconcile, and commit cycle.
  • .tsx files hold components with JSX, and require the react-jsx compiler setting plus @types/react and @types/react-dom.
  • Type props with an interface and use ? for optional props; type state with useState<T> whenever inference from the initial value isn’t enough.
  • Use React’s built-in event types (React.ChangeEvent<HTMLInputElement>, React.MouseEvent<HTMLButtonElement>, etc.) instead of any for event handlers.
  • Type useRef with the DOM element it targets and initialize it with null.
  • Avoid any, avoid React.FC, and keep shared types in a dedicated file for reuse across components.