TypeScript + React Basics
14 examples to get you started with TypeScript + React -- 9 basic and 5 intermediate.
Search across all documentation pages
14 examples to get you started with TypeScript + React -- 9 basic and 5 intermediate.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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:
.tsx. Plain .ts is for non-JSX modules only.strict: true in tsconfig.json -- which is the default in new Next.js and Vite scaffolds.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.
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>
);
}<ComponentName>Props by convention -- easy to find, easy to extend.({ name, age }: GreetingProps).interface for component props -- declaration merging makes it easy to extend; type aliases are fine too.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
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.variant = "primary") fill in when the caller omits the prop."primary" | "secondary") restricts callers to valid options -- autocomplete works out of the box.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
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.ReactElement when you specifically need a single JSX element (e.g., for cloning).type (import type { ReactNode }) -- type-only imports are erased at runtime, keeping the bundle clean.children as a function: children: (state: T) => ReactNode.Related: Typing Props -- children variants (element, function, array) | Composition -- children as the composition primitive
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>
);
}useState(initial) has enough info -- useState(0) is already number.useState<T>(null) when the value could later be something richer -- otherwise TS pins it to null.User | null make the "not loaded yet" state explicit -- consumers must narrow before using fields.Related: Typing State -- complex state shapes, discriminated state | useState -- the underlying hook
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>
</>
);
}ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement>.KeyboardEvent<HTMLInputElement> and read e.key.FormEvent<HTMLFormElement> and remember e.preventDefault().onChange's own signature.Related: Typing Events -- every event type, form patterns | Mouse Events / Form Events -- the underlying APIs
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.inputRef.current?.focus()) -- the ref is null before the element mounts.useRef<T | null>(null) and assign when needed.HTMLInputElement, not HTMLElement) to keep .value, .checked, etc. typed.Related: Typing Refs -- DOM refs, mutable refs,
forwardRef| useRef -- the hook
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}`;
}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.Related: Type Narrowing -- in, typeof, instanceof, assertion functions | Discriminated Unions -- the pattern that makes narrowing clean
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 erroras field is the discriminator -- each value carries different required/forbidden props.never makes it a compile error to mix modes.as unions.Related: Discriminated Unions -- design patterns, type narrowing | Typing Props -- advanced prop shapes
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.Related: Utility Types for React -- every utility with React examples | Typing API Responses -- using utilities at API boundaries
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>}
/>
);
}function List<T>(...)) and reuse T throughout the props.T from the first argument that carries it -- callers rarely write the generic explicitly.extends to constrain the generic: <T extends { id: string | number }> lets you use id without needing keyOf.Related: Generics in React -- generic hooks, constraints, forwardRef | Utility Types -- composing utilities with generics
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;
}undefined and wrap useContext in a custom hook that throws -- consumers get a non-null value.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.Related: Typing Context -- providers, split contexts, selectors | useContext -- the hook
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
}Promise<User> is a lie if you never validate -- the API could return anything.unknown until it passes the schema -- no accidental any leaking through.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
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 });
}params and searchParams are Promises -- always await before reading keys.PageProps / Ctx interface per route; don't reach for a generic AppPageProps.state and FormData explicitly to avoid any.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
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.npm install --save-dev @types/<pkg>) before writing your own..d.ts files tiny and well-named; a hand-rolled declaration that falls out of sync is worse than any.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
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥