TypeScript with React

React and TypeScript are a very common pairing. Types catch wrong or missing props, make hooks safer, and turn your editor into a guide to every component's API. This lesson covers the patterns you'll use daily: typing props, state, events, refs, context and generic components.

Starting a project

$ terminal
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Component files use the .tsx extension, which allows JSX. Frameworks like Next.js and React Router also generate TypeScript projects by default.

Typing props

Describe a component's props with an interface or type alias, and annotate the props parameter:

TSX Badge.tsx
type BadgeProps = {
  label: string;
  tone?: "info" | "success" | "danger";
  count?: number;
};

export function Badge({ label, tone = "info", count }: BadgeProps) {
  return (
    <span className={`badge badge-${tone}`}>
      {label}
      {count !== undefined && <strong> {count}</strong>}
    </span>
  );
}

// <Badge label="Inbox" count={3} />      ✓
// <Badge label="Error" tone="red" />     ✗ "red" is not a valid tone
// <Badge tone="success" />                ✗ label is missing

Optional props get a ?, and default values go in the destructuring pattern. Using a literal union for tone means callers get autocomplete and typos are rejected.

Children and other special props

TSX Card.tsx
import type { ReactNode } from "react";

type CardProps = {
  title: string;
  children: ReactNode;
  footer?: ReactNode;
};

export function Card({ title, children, footer }: CardProps) {
  return (
    <section className="card">
      <h2>{title}</h2>
      {children}
      {footer && <footer>{footer}</footer>}
    </section>
  );
}

ReactNode covers anything React can render: elements, strings, numbers, arrays, null. To accept all the attributes of a native element plus your own, extend React's built-in props type:

TSX Button.tsx
import type { ComponentProps } from "react";

type ButtonProps = ComponentProps<"button"> & {
  variant?: "primary" | "ghost";
};

export function Button({ variant = "primary", ...rest }: ButtonProps) {
  return <button className={`btn btn-${variant}`} {...rest} />;
}

// <Button onClick={save} disabled type="submit">Save</Button>

useState

When the initial value says everything, inference is enough. When it doesn't — such as starting with null or an empty array — pass a type argument:

TSX state.tsx
import { useState } from "react";

type User = { id: number; name: string };

export function Profile() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState<User | null>(null);
  const [tags, setTags] = useState<string[]>([]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <p>{user ? user.name : "Not signed in"}</p>
      <button onClick={() => setTags([...tags, "new"])}>{tags.length} tags</button>
      <button onClick={() => setUser({ id: 1, name: "Ana" })}>Sign in</button>
    </div>
  );
}

Because user is User | null, TypeScript insists on the user ? … : … check before reading user.name.

Events

Inline handlers are typed automatically. When you define a handler separately, use React's event types:

TSX events.tsx
import { useState, type ChangeEvent, type FormEvent } from "react";

export function Search({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState("");

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

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onSearch(query.trim());
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={handleChange} />
    </form>
  );
}

Tip: hover over onChange in your editor to see exactly which event type it expects, then copy it.

useRef

TSX refs.tsx
import { useEffect, useRef } from "react";

export function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);
  const renders = useRef(0);

  useEffect(() => {
    inputRef.current?.focus();
    renders.current += 1;
  });

  return <input ref={inputRef} />;
}

A DOM ref starts as null and is filled in by React after rendering, so inputRef.current is HTMLInputElement | null and needs ?.. A ref used as a mutable box (renders) infers its type from the initial value.

Context

TSX theme.tsx
import { createContext, useContext, useState, type ReactNode } from "react";

type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggle = () => setTheme(t => (t === "light" ? "dark" : "light"));
  return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
  return ctx;
}

Creating the context with null and checking for it in a custom hook means every consumer gets a non-null, fully typed value — and a clear error if the provider is missing.

Generic components

Components can be generic, just like functions. A list that works with any item type and keeps the type in its callbacks:

TSX List.tsx
import type { ReactNode } from "react";

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => string | number;
  render: (item: T) => ReactNode;
};

export function List<T>({ items, getKey, render }: ListProps<T>) {
  return <ul>{items.map(item => <li key={getKey(item)}>{render(item)}</li>)}</ul>;
}

// <List items={users} getKey={u => u.id} render={u => u.name} />
// u is inferred as the user type in both callbacks

Typing custom hooks

TSX useFetch.ts
import { useEffect, useState } from "react";

type FetchState<T> =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: T };

export function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: "loading" });

  useEffect(() => {
    let cancelled = false;
    fetch(url)
      .then(r => (r.ok ? (r.json() as Promise<T>) : Promise.reject(new Error(r.statusText))))
      .then(data => !cancelled && setState({ status: "success", data }))
      .catch((e: unknown) => !cancelled && setState({ status: "error", error: String(e) }));
    return () => { cancelled = true; };
  }, [url]);

  return state;
}

Returning a discriminated union (lesson 23) means components must check status before touching data — no more "data is undefined" crashes during loading.

Note: you'll see React.FC<Props> in older code. It still works, but typing the props parameter directly, as shown throughout this lesson, is now the more common style and handles generics and default values more naturally.