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-appConventions used throughout:
- Every React file is
.tsx. Plain.tsis for non-JSX modules only. strict: trueintsconfig.json-- which is the default in new Next.js and Vite scaffolds.- 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>Propsby convention -- easy to find, easy to extend. - Destructure in the parameter list for clean access:
({ name, age }: GreetingProps). - Prefer
interfacefor component props -- declaration merging makes it easy to extend;typealiases are fine too. - Never use
React.FC-- it adds an implicitchildrenyou 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 addsundefinedto 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>
);
}ReactNodeis the widest "renderable" type -- strings, numbers, elements, arrays, fragments, andnull.- Use
ReactElementwhen 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
childrenas 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 alreadynumber. - Supply
useState<T>(null)when the value could later be something richer -- otherwise TS pins it tonull. - Unions like
User | nullmake 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 reade.key. - For forms use
FormEvent<HTMLFormElement>and remembere.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 }-- thenullmatches React's own type.- Always optional-chain (
inputRef.current?.focus()) -- the ref isnullbefore the element mounts. - For mutable values (timers, cached calculations), use
useRef<T | null>(null)and assign when needed. - Pick the precise element type (
HTMLInputElement, notHTMLElement) 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 objnarrows object shapes without a discriminator.Array.isArray(x)andinstanceofwork 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
asfield is the discriminator -- each value carries different required/forbidden props. - Marking the opposite field as
nevermakes 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
asunions.
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>, andRecord<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 reuseTthroughout the props. - TypeScript infers
Tfrom the first argument that carries it -- callers rarely write the generic explicitly. - Use
extendsto constrain the generic:<T extends { id: string | number }>lets you useidwithout needingkeyOf. - 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
undefinedand wrapuseContextin 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
unknownuntil it passes the schema -- no accidentalanyleaking 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,
paramsandsearchParamsare Promises -- alwaysawaitbefore reading keys. - Define a local
PageProps/Ctxinterface per route; don't reach for a genericAppPageProps. - For the client side of a form, annotate the action's
stateandFormDataexplicitly to avoidany. - Route handlers receive
Request(Web-standard); returnNextResponse.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.tsfiles tiny and well-named; a hand-rolled declaration that falls out of sync is worse thanany. - Use
declare globalfor runtime globals (e.g., augmentingwindow); use module declarations for imports.
Related: Declaration Files -- module, global, and ambient declarations | Strict Mode Patterns -- staying honest when integrating untyped deps