Skip to editor content
learningtypescript.orglesson 24 of 25

React and TypeScript

TypeScript and React work together to catch UI bugs at compile time. Typed props, hooks, and events make components self-documenting and refactor-safe.

How this lesson runs: the runnable fences here are plain TypeScript — the typing patterns React uses (props interfaces, typed reducers, event-payload shapes, generic components), exercised as ordinary functions that print to the console. Real React needs JSX and the framework's runtime, neither of which exists in this sandbox: there is no react package to import and no JSX transform for a .ts file. So the actual-JSX examples below are marked no-run — they show what the finished component looks like, but the runner won't execute them. The logic those components wrap is runnable, and that's what the build task and the interactive fences focus on: everything you can verify by running, you run; JSX is shown for shape.

Typing Props

Define component props with interfaces for type-safe component APIs.

// Props interfaces
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
}

interface CardProps {
  title: string;
  children: string; // simplified — in React this would be ReactNode
  footer?: string;
}

// Simulate component rendering
function Button(props: ButtonProps): string {
  const { label, variant = "primary", disabled = false } = props;
  const state = disabled ? " (disabled)" : "";
  return `<button class="${variant}"${state}>${label}</button>`;
}

function Card(props: CardProps): string {
  const { title, children, footer } = props;
  let html = `<div class="card"><h2>${title}</h2><div>${children}</div>`;
  if (footer) html += `<footer>${footer}</footer>`;
  html += `</div>`;
  return html;
}

console.log(Button({ label: "Submit", onClick: () => {}, variant: "primary" }));
console.log(Button({ label: "Delete", onClick: () => {}, variant: "danger", disabled: true }));
console.log(Card({ title: "Welcome", children: "Hello, TypeScript!" }));
console.log(Card({ title: "Info", children: "With footer", footer: "Last updated today" }));

In a real React project the same ButtonProps interface types an actual JSX component. This fence is no-run — the sandbox has no react package and no JSX transform — but it shows how the props type carries over unchanged; only the return value (string above) becomes real JSX:

import type { ReactNode } from "react";

interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
}

interface CardProps {
  title: string;
  children: ReactNode; // in real React, children is ReactNode, not string
  footer?: string;
}

function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
  return (
    <button className={variant} onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

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

The props interface is identical; the compiler still rejects a missing label or a variant outside the union. Everything you practice below on the logic side transfers directly to typed JSX like this.

Typing Hooks

Type useState, useReducer, and custom hooks for safe state management.

// Simulate React hooks for demonstration

// useState — type is inferred or explicit
function useState<T>(initial: T): [T, (value: T | ((prev: T) => T)) => T] {
  let state = initial;
  const setState = (value: T | ((prev: T) => T)): T => {
    state = typeof value === "function" ? (value as (prev: T) => T)(state) : value;
    return state;
  };
  return [state, setState];
}

// Basic usage — type inferred
const [count, setCount] = useState(0);
console.log(`Count: ${count}`);
console.log(`After increment: ${setCount(prev => prev + 1)}`);

// Explicit type for complex state
interface FormState {
  name: string;
  email: string;
  errors: Record<string, string>;
}

const [form, setForm] = useState<FormState>({
  name: "",
  email: "",
  errors: {}
});
console.log(`Form: ${JSON.stringify(form)}`);

// useReducer pattern
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset"; payload: number };

interface CounterState {
  value: number;
}

function reducer(state: CounterState, action: Action): CounterState {
  switch (action.type) {
    case "increment": return { value: state.value + 1 };
    case "decrement": return { value: state.value - 1 };
    case "reset": return { value: action.payload };
  }
}

let state: CounterState = { value: 10 };
state = reducer(state, { type: "increment" });
console.log(`After increment: ${state.value}`);
state = reducer(state, { type: "decrement" });
console.log(`After decrement: ${state.value}`);
state = reducer(state, { type: "reset", payload: 0 });
console.log(`After reset: ${state.value}`);

Predict

This reducer's Action union requires a value field on the set action, but the dispatch below omits it: { type: 'set' }. This is the kind of mistake a typed reducer is meant to prevent. The code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: what does tsc report, and what does the runner print?

type Action =
| { type: "increment" }
| { type: "set"; value: number };

function reducer(count: number, action: Action): number {
switch (action.type) {
  case "increment": return count + 1;
  case "set": return action.value;
}
}

console.log(reducer(0, { type: "set" }));

Event Handling

Type event handlers to catch errors in event handling code.

// Simulated event payload types. These are named FieldChange / FormSubmit / KeyPress
// rather than ChangeEvent / SubmitEvent / KeyboardEvent because those last two are
// built-in DOM lib globals — reusing the names would shadow them and fail tsc.
// In real React you would import React.ChangeEvent<HTMLInputElement>, etc.
interface FieldChange {
  target: { value: string; name: string };
}

interface FormSubmit {
  preventDefault: () => void;
}

interface KeyPress {
  key: string;
  ctrlKey: boolean;
}

// Typed event handlers
function handleChange(event: FieldChange): void {
  console.log(`Field "${event.target.name}" changed to: ${event.target.value}`);
}

function handleSubmit(event: FormSubmit): void {
  event.preventDefault();
  console.log("Form submitted");
}

function handleKeyDown(event: KeyPress): void {
  if (event.ctrlKey && event.key === "s") {
    console.log("Save shortcut detected");
  } else {
    console.log(`Key pressed: ${event.key}`);
  }
}

// Test the handlers
handleChange({ target: { value: "Alice", name: "username" } });
handleChange({ target: { value: "alice@example.com", name: "email" } });
handleSubmit({ preventDefault: () => console.log("  (default prevented)") });
handleKeyDown({ key: "s", ctrlKey: true });
handleKeyDown({ key: "Enter", ctrlKey: false });

Generic Components

Create reusable components that work with any data type.

// Generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => string;
  emptyMessage?: string;
}

function List<T>(props: ListProps<T>): string {
  const { items, renderItem, emptyMessage = "No items" } = props;
  if (items.length === 0) return `<p>${emptyMessage}</p>`;
  const rendered = items.map((item, i) => `<li>${renderItem(item, i)}</li>`);
  return `<ul>${rendered.join("")}</ul>`;
}

// Use with different types
interface User { name: string; role: string }
interface Product { name: string; price: number }

const users: User[] = [
  { name: "Alice", role: "Admin" },
  { name: "Bob", role: "User" }
];

const products: Product[] = [
  { name: "Laptop", price: 999 },
  { name: "Mouse", price: 29 }
];

console.log("Users:");
console.log(List({
  items: users,
  renderItem: (user) => `${user.name} (${user.role})`
}));

console.log("\nProducts:");
console.log(List({
  items: products,
  renderItem: (product) => `${product.name}: $${product.price}`
}));

console.log("\nEmpty:");
console.log(List<string>({
  items: [],
  renderItem: (s) => s,
  emptyMessage: "Nothing to show"
}));

Recall

Without scrolling up: List<T> is a generic component, and calling List({ items: users, renderItem: (user) => ... }) needed no explicit <User> — user was already typed. You met this inference from arguments in 07-generics. What lets TypeScript know T is User here, and why did the empty-array call List<string>({ items: [], ... }) need the explicit <string>?

Context Typing

Type React context to ensure providers and consumers agree on the shape.

// Simulated context system
interface ThemeContext {
  mode: "light" | "dark";
  primary: string;
  toggle: () => string;
}

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

// Context "providers"
function createThemeContext(): ThemeContext {
  let mode: "light" | "dark" = "light";
  return {
    mode,
    primary: "#007bff",
    toggle() {
      mode = mode === "light" ? "dark" : "light";
      this.mode = mode;
      return mode;
    }
  };
}

function createAuthContext(): AuthContext {
  let user: { name: string; role: string } | null = null;
  return {
    user,
    login(name: string) {
      user = { name, role: "user" };
      this.user = user;
      console.log(`Logged in as ${name}`);
    },
    logout() {
      user = null;
      this.user = null;
      console.log("Logged out");
    }
  };
}

// "Components" consuming context
function Header(theme: ThemeContext, auth: AuthContext): string {
  const greeting = auth.user ? `Welcome, ${auth.user.name}` : "Please log in";
  return `[${theme.mode}] ${greeting}`;
}

const theme = createThemeContext();
const auth = createAuthContext();

console.log(Header(theme, auth));
auth.login("Alice");
console.log(Header(theme, auth));
console.log(`Theme toggled to: ${theme.toggle()}`);
console.log(Header(theme, auth));
auth.logout();
console.log(Header(theme, auth));

Try It Yourself

You've typed props, hooks, events, and generic components. Now build the piece that powers a useReducer hook: the reducer itself, framework-free so it runs here (see How this lesson runs at the top). The Action union and State are from the Typing Hooks section's reducer pattern. Each function is stubbed with only its signature. Run it as-is to see the first failure, implement that, then work down until it prints All checks passed.

Build

Finish the build. Three functions are stubbed with only their signatures; the checks below them fail until each returns the right value. Spec: reducer(state, action) returns a NEW state for each action — increment adds 1 to count, decrement subtracts 1, set replaces count with action.value; every action also appends the resulting count to a copy of history, and the reducer must never mutate the state passed in (end the switch with a never default for exhaustiveness). run(initial, actions) folds the whole action list through reducer, starting from initial. currentValue(state) returns the latest count. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// A typed reducer — the same shape React's useReducer runs, but framework-free so
// it runs here. Domain locked — do not edit.
interface State {
count: number;
history: number[];
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "set"; value: number };

// TODO 1
function reducer(state: State, action: Action): State {
return state;
}

// TODO 2
function run(initial: State, actions: Action[]): State {
return initial;
}

// TODO 3
function currentValue(state: State): number {
return 0;
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const start: State = { count: 0, history: [] };

assert.deepStrictEqual(reducer(start, { type: "increment" }), { count: 1, history: [1] }, "TODO 1: increment adds 1 and records it");
assert.deepStrictEqual(reducer({ count: 5, history: [5] }, { type: "decrement" }), { count: 4, history: [5, 4] }, "TODO 1: decrement subtracts 1 and records it");
assert.deepStrictEqual(reducer(start, { type: "set", value: 9 }), { count: 9, history: [9] }, "TODO 1: set uses the action's value");
assert.deepStrictEqual(start, { count: 0, history: [] }, "TODO 1: reducer must not mutate the state passed in");

const final = run(start, [{ type: "increment" }, { type: "increment" }, { type: "set", value: 10 }, { type: "decrement" }]);
assert.deepStrictEqual(final, { count: 9, history: [1, 2, 10, 9] }, "TODO 2: run folds every action through the reducer");

assert.strictEqual(currentValue(final), 9, "TODO 3: currentValue returns the latest count");

console.log("All checks passed.");
console.log("final:", JSON.stringify(final));
console.log("value:", currentValue(final));

Expected output: All checks passed. final: {"count":9,"history":[1,2,10,9]} value: 9

Once it passes, try two variations and predict each before running:

  1. Forget to record the set action in history. Change the set case to return { count: action.value, history: state.history }; (reuse the old history, don't append). Predict which check fails first before running. The count is right, but history no longer grows for set, so reducer(start, { type: "set", value: 9 }) returns history: [] instead of [9], and TODO 1's third check fails first — AssertionError: TODO 1: set uses the action's value, with history: [] where [9] was expected. Every action records its result; skipping one leaves a gap.
  2. Fold right-to-left in run. Change run to return actions.reduceRight(reducer, initial);. Predict which check fails first before running. reduceRight applies the actions in reverse order — decrement, set 10, increment, increment — so the final count is 12, not 9, and TODO 2's check fails first — count: 12 where 9 was expected. A reducer fold is order-sensitive; dispatch order is the semantics, not an implementation detail.

Arrange the code

Reassemble a program that applies a reducer step by step — the value-level version of what a dispatch loop does. It defines a reducer, folds in one step, folds in another, builds a label, and logs it. The lines are shuffled; each const consumes the binding above it, so only one order runs top-to-bottom and prints count=7.

  1. console.log(label);
  2. const reducer = (count: number, step: number): number => count + step;
  3. const afterFirst = reducer(0, 3);
  4. const afterSecond = reducer(afterFirst, 4);
  5. const label = `count=${afterSecond}`;

Key Takeaways

  • Define props with interfaces — optional props use ?, defaults via destructuring
  • Type useState explicitly when the type can't be inferred from the initial value
  • Use discriminated unions for reducer actions (each action has a unique type)
  • Generic components (List<T>) work with any data type while staying type-safe
  • Type event handlers to match React's event types (React.ChangeEvent, React.FormEvent, etc.)
  • Context types ensure providers and consumers agree on the data shape

Pro Tip: When props get complex, split them into smaller interfaces and compose with intersection types: type Props = LayoutProps & DataProps & EventProps. This keeps each concern focused and makes individual prop groups reusable across components.

Next Steps

You've typed React components with props, hooks, events, and reducers. For the final lesson, you'll apply TypeScript to a full-stack framework: typed page props, generic API wrappers, server-action result unions, and a typed data layer in Next.js.

Ready to continue? Head to Next.js and TypeScript!

Next lesson

Next.js and TypeScript

Apply TypeScript to Next.js patterns including typed page props, API responses, server actions, and generic data fetching.

28 min