React SME Cookbook
All FAQs
basicstypescript-reactexamplestypes

TypeScript + React Basics

14 examples to get you started with TypeScript + React -- 9 basic and 5 intermediate.

Prerequisites

A standard React project with TypeScript is enough. For Next.js:

npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app

Conventions used throughout:

  1. Every React file is .tsx. Plain .ts is for non-JSX modules only.
  2. strict: true in tsconfig.json -- which is the default in new Next.js and Vite scaffolds.
  3. Import shared types from react (ReactNode, ComponentType, FormEvent, etc.) rather than redeclaring them.

New to TypeScript with React? See TypeScript Basics for React for the conceptual introduction to types, interfaces, and tsconfig.


Basic Examples

1. Typing Component Props

Define the shape of a component's props as a TypeScript interface.

interface GreetingProps {
  name: string;
  age: number;
}
 
function Greeting({ name, age }: GreetingProps) {
  return (
    <p>
      {name} is {age}
    </p>
  );
}
  • Name the interface <ComponentName>Props by convention -- easy to find, easy to extend.
  • Destructure in the parameter list for clean access: ({ name, age }: GreetingProps).
  • Prefer interface for component props -- declaration merging makes it easy to extend; type aliases are fine too.
  • Never use React.FC -- it adds an implicit children you often do not want and is falling out of favor in 2026.

Related: Typing Props -- optional props, children, discriminated unions | Components (Fundamentals) -- props in depth


2. Optional Props and Defaults

Mark props optional with ?, then supply a default in the destructure.

interface ButtonProps {
  label: string;
  variant?: "primary" | "secondary";
  disabled?: boolean;
}
 
function Button({ label, variant = "primary", disabled = false }: ButtonProps) {
  return (
    <button className={variant} disabled={disabled}>
      {label}
    </button>
  );
}
  • variant?: ... makes the prop optional; TypeScript adds undefined to its type.
  • Default values in the destructure (variant = "primary") fill in when the caller omits the prop.
  • A union literal type ("primary" | "secondary") restricts callers to valid options -- autocomplete works out of the box.
  • Do not use defaultProps -- it is deprecated for function components in React 19.

Related: Typing Props -- unions, optional chaining, variant props | Discriminated Unions -- when variants carry different fields


3. The children Prop

Accept any renderable content with React.ReactNode.

import type { ReactNode } from "react";
 
interface CardProps {
  title: string;
  children: ReactNode;
}
 
function Card({ title, children }: CardProps) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}
  • ReactNode is the widest "renderable" type -- strings, numbers, elements, arrays, fragments, and null.
  • Use ReactElement when you specifically need a single JSX element (e.g., for cloning).
  • Import as type (import type { ReactNode }) -- type-only imports are erased at runtime, keeping the bundle clean.
  • For render prop APIs, type children as a function: children: (state: T) => ReactNode.

Related: Typing Props -- children variants (element, function, array) | Composition -- children as the composition primitive


4. Typing useState

Let inference do the work, and give an explicit type when the initial value is not enough.

import { useState } from "react";
 
interface User {
  id: string;
  name: string;
}
 
function UserPanel() {
  // Inferred as number from the initial value
  const [count, setCount] = useState(0);
 
  // Explicit generic needed when initial value does not carry the full type
  const [user, setUser] = useState<User | null>(null);
 
  return (
    <p>
      {count} — {user?.name ?? "no user"}
    </p>
  );
}
  • Skip the generic when useState(initial) has enough info -- useState(0) is already number.
  • Supply useState<T>(null) when the value could later be something richer -- otherwise TS pins it to null.
  • Unions like User | null make the "not loaded yet" state explicit -- consumers must narrow before using fields.
  • Do not store derived values in state. Compute during render from simpler state.

Related: Typing State -- complex state shapes, discriminated state | useState -- the underlying hook


5. Typing Event Handlers

Use the generic event types that React exports so e.target, e.key, and friends stay typed.

import type { ChangeEvent, MouseEvent } from "react";
import { useState } from "react";
 
export default function SearchBar() {
  const [q, setQ] = useState("");
 
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setQ(e.target.value);
  };
 
  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    console.log("pressed at", e.clientX, e.clientY);
  };
 
  return (
    <>
      <input value={q} onChange={handleChange} />
      <button onClick={handleClick}>Search</button>
    </>
  );
}
  • Event types are generic on the target element: ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement>.
  • For keyboard interactions use KeyboardEvent<HTMLInputElement> and read e.key.
  • For forms use FormEvent<HTMLFormElement> and remember e.preventDefault().
  • If you keep the handler inline, you can skip the annotation -- TypeScript infers it from onChange's own signature.

Related: Typing Events -- every event type, form patterns | Mouse Events / Form Events -- the underlying APIs


6. useRef for a DOM Node

Type useRef with the element it will reference, initialized to null.

import { useEffect, useRef } from "react";
 
function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);
 
  useEffect(() => {
    inputRef.current?.focus();
  }, []);
 
  return <input ref={inputRef} />;
}
  • useRef<HTMLInputElement>(null) gives { current: HTMLInputElement | null } -- the null matches React's own type.
  • Always optional-chain (inputRef.current?.focus()) -- the ref is null before the element mounts.
  • For mutable values (timers, cached calculations), use useRef<T | null>(null) and assign when needed.
  • Pick the precise element type (HTMLInputElement, not HTMLElement) to keep .value, .checked, etc. typed.

Related: Typing Refs -- DOM refs, mutable refs, forwardRef | useRef -- the hook


7. Type Narrowing with in, typeof, and Discriminators

Turn a union into a specific branch so fields are safe to access.

type Response =
  | { kind: "ok"; data: string }
  | { kind: "error"; message: string };
 
function render(res: Response): string {
  if (res.kind === "ok") {
    // res is now { kind: "ok"; data: string }
    return `Got: ${res.data}`;
  }
  return `Failed: ${res.message}`;
}
  • A discriminator property (kind) lets TypeScript narrow the union with a single check.
  • typeof value === "string" narrows primitives; "field" in obj narrows object shapes without a discriminator.
  • Array.isArray(x) and instanceof work too -- TS knows each of these narrows the type.
  • Exhaustiveness: handle every branch so adding a new one forces a compile error in every call site.

Related: Type Narrowing -- in, typeof, instanceof, assertion functions | Discriminated Unions -- the pattern that makes narrowing clean


8. Discriminated Union Props

Encode "either this mode or that mode" so invalid prop combinations fail at compile time.

type LinkProps =
  | { as: "button"; onClick: () => void; href?: never }
  | { as: "anchor"; href: string; onClick?: never };
 
function ActionLink(props: LinkProps) {
  if (props.as === "button") {
    return <button onClick={props.onClick}>Click</button>;
  }
  return <a href={props.href}>Click</a>;
}
 
// <ActionLink as="button" onClick={...} />   ✓
// <ActionLink as="anchor" href="..." />      ✓
// <ActionLink as="anchor" onClick={...} />   ✗ type error
  • The as field is the discriminator -- each value carries different required/forbidden props.
  • Marking the opposite field as never makes it a compile error to mix modes.
  • Prefer a discriminated union over "optional props + runtime check" -- invalid calls fail before they ship.
  • The pattern scales: buttons, inputs, and cards all benefit from as unions.

Related: Discriminated Unions -- design patterns, type narrowing | Typing Props -- advanced prop shapes


9. Utility Types (Pick, Omit, Partial)

Derive new types from existing ones instead of writing two interfaces that drift.

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
 
type UserSummary = Pick<User, "id" | "name">;
type NewUser = Omit<User, "id" | "createdAt">;
type UserUpdate = Partial<Omit<User, "id">>;
  • Pick<T, K> keeps only the listed keys; Omit<T, K> removes them -- compose to express API shapes.
  • Partial<T> makes every property optional; handy for PATCH endpoints and form state.
  • Required<T>, Readonly<T>, and Record<K, V> round out the most-used utilities.
  • Changing the source interface automatically flows through -- no hand-synced duplicates.

Related: Utility Types for React -- every utility with React examples | Typing API Responses -- using utilities at API boundaries


Intermediate Examples

10. Generic Components

Let the caller specify the item type so the component stays typed for any list.

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyOf: (item: T) => string | number;
}
 
function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyOf(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
 
// Usage — T is inferred from `items`
function UsersPage({ users }: { users: { id: string; name: string }[] }) {
  return (
    <List
      items={users}
      keyOf={(u) => u.id}
      renderItem={(u) => <span>{u.name}</span>}
    />
  );
}
  • Declare the generic on the component (function List<T>(...)) and reuse T throughout the props.
  • TypeScript infers T from the first argument that carries it -- callers rarely write the generic explicitly.
  • Use extends to constrain the generic: <T extends { id: string | number }> lets you use id without needing keyOf.
  • Works for tables, select controls, auto-complete fields -- any "list of X" component.

Related: Generics in React -- generic hooks, constraints, forwardRef | Utility Types -- composing utilities with generics


11. Typed Context

Share data via context without a non-null assertion on every consumer.

"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
 
interface AuthValue {
  user: { id: string; name: string } | null;
  login: (name: string) => void;
}
 
const AuthCtx = createContext<AuthValue | undefined>(undefined);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<AuthValue["user"]>(null);
  const login = (name: string) => setUser({ id: crypto.randomUUID(), name });
 
  return <AuthCtx.Provider value={{ user, login }}>{children}</AuthCtx.Provider>;
}
 
export function useAuth() {
  const ctx = useContext(AuthCtx);
  if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx;
}
  • Initialize context with undefined and wrap useContext in a custom hook that throws -- consumers get a non-null value.
  • Export only the hook (useAuth), not the raw context -- consumers cannot bypass the check.
  • AuthValue["user"] indexes into the interface so the setter stays in sync with the shape.
  • For performance, split read/write contexts (see the React Patterns basics page).

Related: Typing Context -- providers, split contexts, selectors | useContext -- the hook


12. Typed fetch with Zod

Guarantee at runtime that API responses match the TypeScript type you ship.

import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});
 
type User = z.infer<typeof UserSchema>;
 
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const json: unknown = await res.json();
  return UserSchema.parse(json); // validates + narrows to User
}
  • Typing a response as Promise<User> is a lie if you never validate -- the API could return anything.
  • A Zod schema validates at runtime and infers the TypeScript type, so there is only one thing to maintain.
  • Treat the raw response as unknown until it passes the schema -- no accidental any leaking through.
  • For query-heavy apps, combine with TanStack Query's queryFn + schema so every query in the app is typed end-to-end.

Related: Typing API Responses -- patterns, error shapes, schema composition | Zod Basics -- the Zod primer


13. Typing Server Components and Route Handlers

Next.js 15 makes params and searchParams async -- type them as Promise<T>.

// app/posts/[id]/page.tsx
interface PageProps {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ preview?: string }>;
}
 
export default async function PostPage({ params, searchParams }: PageProps) {
  const { id } = await params;
  const { preview } = await searchParams;
 
  return <p>Post {id} {preview ? "(preview)" : ""}</p>;
}
// app/api/posts/[id]/route.ts
import { NextResponse } from "next/server";
 
interface Ctx {
  params: Promise<{ id: string }>;
}
 
export async function GET(_req: Request, ctx: Ctx) {
  const { id } = await ctx.params;
  return NextResponse.json({ id });
}
  • In Next.js 15, params and searchParams are Promises -- always await before reading keys.
  • Define a local PageProps / Ctx interface per route; don't reach for a generic AppPageProps.
  • For the client side of a form, annotate the action's state and FormData explicitly to avoid any.
  • Route handlers receive Request (Web-standard); return NextResponse.json(body) for typed JSON responses.

Related: Typing Server Components -- props, async components, boundaries | Typing Route Handlers -- GET/POST/PUT signatures, middleware


14. Declaration File for an Untyped Module

Write a .d.ts to add types to a plain JavaScript package that ships no types of its own.

// types/colorful-logger.d.ts
declare module "colorful-logger" {
  export interface LogOptions {
    color?: "red" | "green" | "blue";
    bold?: boolean;
  }
 
  export function log(message: string, options?: LogOptions): void;
  export function clear(): void;
}

Then in tsconfig.json:

{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./types"]
  }
}
  • declare module "<name>" tells TypeScript the public surface of a module that has no built-in types.
  • Check DefinitelyTyped first (npm install --save-dev @types/<pkg>) before writing your own.
  • Keep ambient .d.ts files tiny and well-named; a hand-rolled declaration that falls out of sync is worse than any.
  • Use declare global for runtime globals (e.g., augmenting window); use module declarations for imports.

Related: Declaration Files -- module, global, and ambient declarations | Strict Mode Patterns -- staying honest when integrating untyped deps