TypeScript · Lesson 11 of 12

TypeScript with React

Use TypeScript with React: typing props and children, useState, useRef and useReducer, event handlers, context, custom hooks and generic components.

  • Intermediate
  • 18 min read
  • 4 objectives

Before this lessonLesson 10: tsconfig, Strict Mode and Tooling

What you will learn

  • Type component props, children and defaults
  • Type hooks: useState, useRef, useReducer and context
  • Type events and forms
  • Write generic, reusable components

Your Progress

0 of 12 lessons 0%

  • Lessons0 / 12
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

React and TypeScript are the default pairing for front-end work in 2026: Vite, Next.js and React Router all start projects in TypeScript. Types make components self-documenting (hover a component and see exactly what it accepts) and catch the most common React bugs: missing props, wrong event types and state that might be null.

This lesson assumes you know basic React. To follow along, create a project with npm create vite@latest my-app -- --template react-ts. Component files use the .tsx extension.

Typing props

Props are just a function parameter, so you type them with an interface or type alias. Optional props get ?, and destructuring defaults fill them in. You do not need React.FC; a plain function with typed props is the recommended style.

interface BadgeProps {
  label: string;
  tone?: "neutral" | "success" | "danger";
  count?: number;
}

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

// <Badge label="Orders" tone="success" count={3} />   OK
// <Badge tone="info" />   Error: 'label' is missing, and "info" is not a valid tone

Children and wrapping native elements

Use React.ReactNode for anything renderable (text, elements, arrays, null). When your component wraps a native element, extend its props with ComponentProps<"button"> so callers can pass onClick, disabled, aria-* and everything else without you listing them.

import type { ComponentProps, ReactNode } from "react";

interface CardProps {
  title: string;
  children: ReactNode;
}

export function Card({ title, children }: CardProps) {
  return <section><h2>{title}</h2>{children}</section>;
}

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

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

In React 19, ref is a normal prop for function components, so ComponentProps<"button"> already includes it and forwardRef is no longer needed for new code.

useState and useRef

useState infers its type from the initial value, so useState(0) is a number. You only need a type argument when the initial value does not show the full type, typically an empty array or null.

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

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

export function Profile() {
  const [count, setCount] = useState(0);                 // number (inferred)
  const [tags, setTags] = useState<string[]>([]);        // without <string[]> this is never[]
  const [user, setUser] = useState<User | null>(null);   // may be null until loaded
  const inputRef = useRef<HTMLInputElement>(null);       // DOM ref

  useEffect(() => {
    inputRef.current?.focus();                           // current may be null
  }, []);

  if (!user) return <p>Loading...</p>;
  return <input ref={inputRef} defaultValue={user.name} />;   // user: User here
}

Event handlers and forms

Inline handlers are typed for you by context. When you extract a handler into its own function, use React's event types, which are generic over the element: React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>, React.MouseEvent<HTMLButtonElement>. A quick trick: hover the onChange prop in your editor to see the exact type.

import { useState, type ChangeEvent, type FormEvent } from "react";

export function SignupForm({ onSubmit }: { onSubmit: (email: string) => void }) {
  const [email, setEmail] = useState("");

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setEmail(e.target.value);        // e.target is an HTMLInputElement
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onSubmit(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={handleChange} />
      <button type="submit">Join stackcone</button>
    </form>
  );
}

useReducer with a discriminated union

For state with several related transitions, useReducer plus a discriminated union of actions is where TypeScript shines: each case knows exactly which payload its action carries, and a typo in an action type is a compile error. The reducer is plain logic, so here it is in JavaScript with its output:

function cartReducer(state, action) {
  switch (action.type) {
    case "add":
      return { items: [...state.items, action.item] };
    case "remove":
      return { items: state.items.filter((i) => i.sku !== action.sku) };
    case "clear":
      return { items: [] };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

let state = { items: [] };
state = cartReducer(state, { type: "add", item: { sku: "pen", price: 3 } });
state = cartReducer(state, { type: "add", item: { sku: "book", price: 12 } });
state = cartReducer(state, { type: "remove", sku: "pen" });
console.log(JSON.stringify(state));
Output
{"items":[{"sku":"book","price":12}]}

And the TypeScript types that make it safe:

interface Item { sku: string; price: number; }
interface CartState { items: Item[]; }

type CartAction =
  | { type: "add"; item: Item }
  | { type: "remove"; sku: string }
  | { type: "clear" };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add":    return { items: [...state.items, action.item] };   // action.item: Item
    case "remove": return { items: state.items.filter((i) => i.sku !== action.sku) };
    case "clear":  return { items: [] };
  }
}

// In a component:
// const [cart, dispatch] = useReducer(cartReducer, { items: [] });
// dispatch({ type: "remove", sku: "pen" });   OK
// dispatch({ type: "remove" });               Error: 'sku' is missing

Context and custom hooks

A context usually has no sensible default value, so type it as T | null and wrap access in a custom hook that throws if the provider is missing. Every consumer then gets a non-null value without repeating checks.

import { createContext, useContext, useState, type ReactNode } from "react";

interface AuthContextValue {
  user: { name: string } | null;
  login: (name: string) => void;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<AuthContextValue["user"]>(null);
  return (
    <AuthContext value={{ user, login: (name) => setUser({ name }) }}>
      {children}
    </AuthContext>
  );
}

export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx;
}

React 19 lets you render <AuthContext> directly as the provider; in older versions write <AuthContext.Provider>.

Generic components

Components can be generic, just like functions. A list or table that works for any row type, while keeping renderItem fully typed, is the classic example.

import type { Key, ReactNode } from "react";

interface ListProps<T> {
  items: T[];
  getKey: (item: T) => Key;
  renderItem: (item: T) => ReactNode;
}

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

// T is inferred as { id: string; total: number }, so o.total is typed
// <List items={orders} getKey={(o) => o.id} renderItem={(o) => `$${o.total}`} />

Recap

  • Type props with an interface; skip React.FC; use ReactNode for children.
  • Extend ComponentProps<"button"> to wrap native elements; in React 19 ref is a normal prop.
  • Give useState a type argument when starting from [] or null; type DOM refs as useRef<HTMLInputElement>(null).
  • Use React's generic event types for extracted handlers, and discriminated unions for reducer actions.
  • Type context as T | null behind a custom hook that throws; make reusable components generic.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 12TypeScript in Real Projectstsconfig, strict mode, typing APIs and React props.