Skip to content
elephantoo

TypeScript with React

Lesson 25 of 30 18 min read

Typing props, children, state, events, refs, context, reducers and generic components.


TypeScript adds types to JavaScript, and it pairs brilliantly with React: misspelled props, missing props, wrong event types and undefined crashes get caught in your editor instead of in production. Most professional React codebases use TypeScript, and the Vite react-ts template sets it up for you. This lesson covers the patterns you'll use every day.

Setup#

Terminal
npm create vite@latest my-app -- --template react-ts

Component files use the .tsx extension. The template includes a strict tsconfig and the React type definitions (@types/react, @types/react-dom). Vite strips types when serving your app (it doesn't type-check), so run the compiler to check — the template's build script does this:

Terminal
npx tsc -b      # type-check the project
npm run build   # runs tsc -b && vite build

Tip: your editor (VS Code) type-checks as you type, which is where TypeScript is most helpful.

Typing props#

Describe props with a type (or interface) and annotate the parameter:

TSX
type ButtonProps = {
  label: string;
  variant?: "primary" | "secondary"; // optional, limited to these values
  disabled?: boolean;
  onClick: () => void;
};

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

export function Demo() {
  return <Button label="Save" variant="primary" onClick={() => console.log("saved")} />;
  // <Button label="Save" variant="danger" />  ❌ "danger" isn't an allowed variant (and onClick is required)
}
  • ? makes a prop optional; give it a default with destructuring.
  • String literal unions ("primary" | "secondary") give you autocomplete and catch typos.
  • You don't need to annotate the return type — TypeScript infers it.

children and other JSX props#

TSX
import type { ReactNode } from "react";

type CardProps = {
  title: string;
  footer?: ReactNode; // a "slot" for any JSX
  children: ReactNode;
};

export function Card({ title, footer, children }: CardProps) {
  return (
    <article>
      <h3>{title}</h3>
      {children}
      {footer && <footer>{footer}</footer>}
    </article>
  );
}

export const example = (
  <Card title="Welcome" footer={<small>Updated today</small>}>
    <p>Hello!</p>
  </Card>
);

Use ReactNode for anything renderable. Use React.JSX.Element only when you need specifically one element.

Extending native element props#

A custom Input should accept everything a real <input> does. Use ComponentProps:

TSX
import type { ComponentProps } from "react";

type InputProps = ComponentProps<"input"> & {
  label: string;
  error?: string;
};

export function Input({ label, error, id, ...rest }: InputProps) {
  const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-");
  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input id={inputId} aria-invalid={Boolean(error)} {...rest} />
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

export const field = <Input label="Email" type="email" placeholder="you@example.com" required />;

ComponentProps<"input"> includes ref too, so in React 19 your component can receive and forward a ref without forwardRef.

State#

useState infers the type from the initial value. Add a type argument when the initial value doesn't tell the whole story:

TSX
import { useState } from "react";

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

export function Profile() {
  const [count, setCount] = useState(0); // number (inferred)
  const [user, setUser] = useState<User | null>(null); // null until loaded
  const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");

  async function load() {
    setStatus("loading");
    try {
      const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
      setUser((await res.json()) as User);
      setStatus("idle");
    } catch {
      setStatus("error");
    }
  }

  return (
    <div>
      <button onClick={load}>Load ({count})</button>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      {status === "loading" && <p>Loading…</p>}
      {/* user.name ❌ – 'user' is possibly 'null' */}
      {user && <p>{user.name}</p>}
    </div>
  );
}

Tip: res.json() returns any — TypeScript can't know what the server sends. as User is a promise you make. For untrusted data, validate at runtime with a library like Zod.

Events#

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

TSX
import { useState, type ChangeEvent, type MouseEvent, type SubmitEvent } from "react";

export function Signup() {
  const [email, setEmail] = useState("");

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

  function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(data.get("email"));
  }

  function handleReset(e: MouseEvent<HTMLButtonElement>) {
    console.log("clicked at", e.clientX, e.clientY);
    setEmail("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={email} onChange={handleChange} />
      <button>Sign up</button>
      <button type="button" onClick={handleReset}>
        Clear
      </button>
    </form>
  );
}

Don't remember a type? Write the handler inline, hover over e in your editor, and copy the type. (Use SubmitEvent for form submissions — the older FormEvent type is deprecated.)

Refs#

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

export function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null); // DOM ref: start with null
  const renderCount = useRef(0); // value ref: inferred as number

  useEffect(() => {
    inputRef.current?.focus(); // `current` may be null, so use ?.
    renderCount.current += 1;
  });

  return <input ref={inputRef} placeholder="Search…" />;
}

Reducers with discriminated unions#

TSX
import { useReducer } from "react";

type Todo = { id: number; text: string; done: boolean };

type Action =
  | { type: "added"; text: string }
  | { type: "toggled"; id: number }
  | { type: "deleted"; id: number };

function todosReducer(todos: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case "added":
      return [...todos, { id: Date.now(), text: action.text, done: false }]; // action.text ✅
    case "toggled":
      return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "deleted":
      return todos.filter((t) => t.id !== action.id);
    default: {
      const unreachable: never = action; // ❌ compile error if you forget a case
      return unreachable;
    }
  }
}

export function Todos() {
  const [todos, dispatch] = useReducer(todosReducer, []);
  // dispatch({ type: "add", text: "x" })  ❌ '"add"' is not assignable
  return (
    <>
      <button onClick={() => dispatch({ type: "added", text: "Learn TS" })}>Add</button>
      <ul>
        {todos.map((t) => (
          <li key={t.id} onClick={() => dispatch({ type: "toggled", id: t.id })}>
            {t.done ? "✅" : "⬜"} {t.text}
          </li>
        ))}
      </ul>
    </>
  );
}

The never trick in default is called exhaustiveness checking: add a new action type and TypeScript points at every reducer that doesn't handle it.

Context with a safe custom hook#

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

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

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

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

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

Consumers call useAuth() and get a non-null value — no ?. everywhere.

Generic components#

A component can be generic, just like a function. This List works with any item type and keeps renderItem type-safe:

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

type 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>
  );
}

type Course = { slug: string; title: string; lessons: number };
const courses: Course[] = [{ slug: "react", title: "React", lessons: 30 }];

export const courseList = (
  <List
    items={courses}
    getKey={(c) => c.slug}
    renderItem={(c) => `${c.title} (${c.lessons} lessons)`} // c is Course ✅
  />
);

Typing custom hooks#

Return tuples with as const so each position keeps its own type:

TSX
import { useCallback, useState } from "react";

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const; // readonly [boolean, () => void]
}

export function Panel() {
  const [open, toggle] = useToggle();
  return <button onClick={toggle}>{open ? "Hide" : "Show"}</button>;
}

Without as const, the return type would be (boolean | (() => void))[], and open couldn't be used as a boolean.

Useful type helpers#

HelperUse
ComponentProps<"button">All props of a native element
ComponentProps<typeof MyComp>Props of an existing component
ReactNodeAnything renderable
Dispatch<SetStateAction<T>>The type of a setState function you pass as a prop
CSSPropertiesA style object
Ref<T>A ref prop you accept

Common mistakes#

  • Using any to silence errors — it switches type checking off. Use unknown and narrow, or fix the type.
  • Typing components as React.FC — it works, but a plain annotated function is simpler and the community default.
  • useRef<HTMLInputElement>() without null (an error in React 19's types — pass an initial value).
  • Assuming as SomeType validates data. It doesn't; it only tells the compiler to trust you.
  • Ignoring red squiggles because "Vite still runs it" — Vite doesn't type-check; run tsc.

What's next#

Types catch one class of bugs; tests catch the rest. Next: testing React components with Vitest and React Testing Library.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What's the right type for a children prop that can hold any renderable content?

  2. 2.How should you type const inputRef = useRef(...) for a DOM input?

  3. 3.Why use a discriminated union for reducer actions, like { type: 'added'; text: string } | { type: 'deleted'; id: number }?

Finished reading?

Mark this lesson complete to track your progress.