As React applications grow, keeping track of component props, state, API responses, and shared data becomes increasingly difficult.
JavaScript gives developers flexibility, but that flexibility can also lead to runtime errors that are difficult to catch before users encounter them.
This is where TypeScript comes in.
TypeScript adds a static type system to JavaScript. It helps identify mistakes during development, improves editor autocomplete, and makes refactoring safer.
In this guide, we'll learn how to use TypeScript effectively in React applications, from basic component props to advanced generics and API data validation.
By the end, you'll be able to build React components that are easier to understand, maintain, and scale.
1. Why Use TypeScript with React?
TypeScript provides several advantages:
- Catch errors earlier: Detect incorrect prop values and incompatible types before running your application.
- Better autocomplete: Get useful suggestions for component props, state, and function arguments.
- Safer refactoring: Rename properties and update function signatures with more confidence.
- Clearer contracts: Make it easier to understand what a component expects and returns.
- Improved collaboration: Help teammates understand how different parts of an application work together.
Consider this JavaScript component:
function UserProfile({ user }) {
return <h2>{user.name}</h2>;
}
What happens if someone passes a string instead of a user object?
JavaScript won't catch the problem until runtime.
With TypeScript:
type User = {
id: number;
name: string;
email: string;
};
type UserProfileProps = {
user: User;
};
function UserProfile({ user }: UserProfileProps) {
return <h2>{user.name}</h2>;
}
Now TypeScript can detect incorrect usage before the application runs.
Important: TypeScript does not validate data at runtime. Its types are removed during compilation, so external data—such as API responses—still needs runtime validation.
2. Setting Up a React + TypeScript Project
The easiest way to start a new React TypeScript application is with Vite.
Run:
npm create vite@latest my-react-app -- --template react-ts
Then install dependencies:
cd my-react-app
npm install
npm run dev
Your project will contain files similar to:
my-react-app/
├── src/
│ ├── App.tsx
│ ├── main.tsx
│ ├── index.css
│ └── assets/
├── public/
├── package.json
├── tsconfig.json
└── vite.config.ts
.tsx vs .ts
Use:
-
.tsxfor TypeScript files that contain JSX. -
.tsfor TypeScript files without JSX.
Examples:
Button.tsx
UserProfile.tsx
useProducts.ts
api.ts
types.ts
Understanding tsconfig.json
The TypeScript configuration file controls how TypeScript checks and compiles your code.
Some important options include:
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}
This is an illustration of useful compiler options, not a complete replacement for Vite's generated configuration.
The most important option is:
"strict": true
It enables a collection of stricter type-checking rules and helps prevent many common mistakes.
For most React projects, keeping strict mode enabled is a great starting point.
3. TypeScript Fundamentals
Before typing React components, let's review the essential TypeScript types.
Basic Types
const username: string = "Alex";
const age: number = 28;
const isAdmin: boolean = false;
const scores: number[] = [90, 85, 100];
TypeScript can often infer these types automatically:
const username = "Alex";
const age = 28;
You don't need to explicitly annotate every variable.
Object Types
type User = {
id: number;
name: string;
email: string;
isActive: boolean;
};
const user: User = {
id: 1,
name: "Alex",
email: "alex@example.com",
isActive: true
};
Optional Properties
Use ? when a property is not required:
type User = {
id: number;
name: string;
avatar?: string;
};
Now both of these objects are valid:
const userOne: User = {
id: 1,
name: "Alex"
};
const userTwo: User = {
id: 2,
name: "Sarah",
avatar: "/avatars/sarah.png"
};
Union Types
A union allows a value to have more than one possible type:
let userId: string | number;
userId = 101;
userId = "user-101";
Union types are especially useful for application states:
type Status = "idle" | "loading" | "success" | "error";
Now TypeScript only accepts one of those four values.
any vs unknown
Avoid using any whenever possible.
let value: any = "hello";
value.toFixed(); // TypeScript won't protect you here.
unknown is safer:
let value: unknown = "hello";
// You must narrow the type before using it.
if (typeof value === "string") {
console.log(value.toUpperCase());
}
Use unknown when you receive data whose type hasn't been established yet.
4. Typing React Component Props
Props are one of the most common places where TypeScript becomes useful.
You can define props using either type or interface.
Using type
type GreetingProps = {
name: string;
age: number;
};
function Greeting({ name, age }: GreetingProps) {
return (
<div>
<h2>Hello, {name}!</h2>
<p>You are {age} years old.</p>
</div>
);
}
export default Greeting;
Usage:
<Greeting name="Alex" age={28} />
Passing an incorrect type will produce a TypeScript error:
<Greeting name="Alex" age="twenty-eight" />
Using interface
interface ProductProps {
title: string;
price: number;
}
function ProductCard({ title, price }: ProductProps) {
return (
<article>
<h2>{title}</h2>
<p>${price}</p>
</article>
);
}
Both type and interface are perfectly valid for describing object-shaped props.
A practical guideline:
- Use
interfacewhen describing object contracts that may be extended or implemented. - Use
typewhen working with unions, intersections, and more complex type expressions.
You don't need to enforce a strict rule. Consistency matters more.
5. Working with Children Props
React components often need to accept nested content.
Use React.ReactNode to describe content that React can render:
import type { ReactNode } from "react";
type CardProps = {
title: string;
children: ReactNode;
};
function Card({ title, children }: CardProps) {
return (
<section className="card">
<h2>{title}</h2>
<div>{children}</div>
</section>
);
}
Usage:
<Card title="Welcome">
<p>This is some content inside the card.</p>
<button>Learn More</button>
</Card>
You can also make children optional:
type CardProps = {
title: string;
children?: ReactNode;
};
Another option is PropsWithChildren:
import type { PropsWithChildren } from "react";
type PanelProps = PropsWithChildren<{
title: string;
}>;
function Panel({ title, children }: PanelProps) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}
Remember that PropsWithChildren makes children optional. If your component requires children, explicitly declare children: ReactNode.
6. Typing Event Handlers
React event handlers are another important part of TypeScript development.
You can use React's event types to describe the events your functions receive.
Input Events
import type { ChangeEvent } from "react";
function SearchInput() {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
console.log(event.target.value);
};
return (
<input
type="text"
placeholder="Search..."
onChange={handleChange}
/>
);
}
Form Events
import type { FormEvent } from "react";
function LoginForm() {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log("Form submitted!");
};
return (
<form onSubmit={handleSubmit}>
<input type="email" placeholder="Email" />
<button type="submit">Login</button>
</form>
);
}
Mouse Events
import type { MouseEvent } from "react";
function ActionButton() {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
console.log(event.currentTarget);
};
return (
<button onClick={handleClick}>
Click Me
</button>
);
}
Common React Event Types
| Event | Type |
|---|---|
| Input change | ChangeEvent<HTMLInputElement> |
| Textarea change | ChangeEvent<HTMLTextAreaElement> |
| Form submit | FormEvent<HTMLFormElement> |
| Button click | MouseEvent<HTMLButtonElement> |
| Select change | ChangeEvent<HTMLSelectElement> |
You don't always need to write these types manually.
TypeScript can infer the event type when you define the handler inline:
function SearchInput() {
return (
<input
onChange={(event) => {
console.log(event.target.value);
}}
/>
);
}
Use explicit event types when defining reusable handler functions or when inference isn't sufficient.
7. Typing useState
React's useState works naturally with TypeScript.
Primitive State
TypeScript usually infers the type automatically:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Since the initial value is a number, TypeScript knows that count is a number.
Array State
When initializing an empty array, provide its element type:
type Product = {
id: number;
name: string;
};
function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const addProduct = () => {
setProducts((currentProducts) => [
...currentProducts,
{ id: 1, name: "Laptop" }
]);
};
return (
<div>
{products.map((product) => (
<p key={product.id}>{product.name}</p>
))}
<button onClick={addProduct}>Add Product</button>
</div>
);
}
Nullable State
Sometimes a state value starts as null and receives an object later.
type User = {
id: number;
name: string;
};
function UserProfile() {
const [user, setUser] = useState<User | null>(null);
return (
<div>
{user ? <h2>{user.name}</h2> : <p>No user selected.</p>}
</div>
);
}
The union User | null makes the possible states explicit.
Union State
type Status = "idle" | "loading" | "success" | "error";
function DataLoader() {
const [status, setStatus] = useState<Status>("idle");
return (
<div>
<p>Current status: {status}</p>
<button onClick={() => setStatus("loading")}>
Start Loading
</button>
</div>
);
}
A useful rule: let TypeScript infer types when it can, but provide explicit types when the initial value doesn't communicate enough information.
8. Typing useRef
Refs are commonly used to access DOM elements or store mutable values.
DOM References
import { useRef } from "react";
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>
Focus Input
</button>
</div>
);
}
Here:
-
HTMLInputElementdescribes the DOM element. -
nullrepresents the initial state before React attaches the element. - Optional chaining prevents accessing a method when the ref is still null.
Mutable Values
Refs can also store values that don't need to trigger a render:
import { useRef } from "react";
function Timer() {
const renderCount = useRef(0);
renderCount.current += 1;
return <p>Render count: {renderCount.current}</p>;
}
The value of a ref persists across renders, but changing it does not trigger a new render.
In real applications, avoid using a ref to manage data that should be displayed reactively. That's what state is for.
9. Typing useEffect
Most effects can rely on TypeScript's inference.
import { useEffect, useState } from "react";
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <p>Window width: {width}px</p>;
}
TypeScript understands the effect callback and its cleanup function.
You generally don't need to manually annotate the return type of useEffect.
10. Typing Custom Hooks
Custom Hooks benefit greatly from TypeScript because they often return data and functions that other components reuse.
Consider a Hook for managing a counter:
import { useState } from "react";
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((value) => value + 1);
const decrement = () => setCount((value) => value - 1);
const reset = () => setCount(initialValue);
return {
count,
increment,
decrement,
reset
};
}
Usage:
function Counter() {
const { count, increment, decrement, reset } = useCounter(5);
return (
<div>
<h2>{count}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
TypeScript automatically infers the Hook's parameter and return types.
You can add explicit return types when they improve clarity or when a public API needs a stable contract.
type CounterResult = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
function useCounter(initialValue = 0): CounterResult {
const [count, setCount] = useState(initialValue);
return {
count,
increment: () => setCount((value) => value + 1),
decrement: () => setCount((value) => value - 1),
reset: () => setCount(initialValue)
};
}
11. Typing React Context Safely
Context allows components to share data without passing props through every level of the component tree.
Let's create a theme context.
First, define the context type:
type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
toggleTheme: () => void;
};
Next, create the context:
import { createContext } from "react";
const ThemeContext = createContext<ThemeContextValue | undefined>(
undefined
);
Create a provider:
import { useState } from "react";
import type { ReactNode } from "react";
type ThemeProviderProps = {
children: ReactNode;
};
function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>("light");
const toggleTheme = () => {
setTheme((current) =>
current === "light" ? "dark" : "light"
);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
Create a custom Hook that safely accesses the context:
import { useContext } from "react";
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return context;
}
Now any component can use it:
function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
This pattern is better than using an empty object or forcing a type assertion to pretend the context always has a value.
It gives you a clear error when a component is rendered outside its provider.
12. Typing API Responses
One of the most important TypeScript lessons is understanding that external data is not automatically safe.
Consider an API response:
{
"id": 1,
"name": "Laptop",
"price": 1200
}
We can describe the expected structure:
type Product = {
id: number;
name: string;
price: number;
};
However, this does not guarantee that the server actually sends that structure.
A Basic API Function
async function fetchProducts(): Promise<Product[]> {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to fetch products");
}
const data: unknown = await response.json();
if (!Array.isArray(data)) {
throw new Error("Invalid products response");
}
return data.map((item): Product => {
if (
typeof item !== "object" ||
item === null ||
!("id" in item) ||
!("name" in item) ||
!("price" in item) ||
typeof item.id !== "number" ||
typeof item.name !== "string" ||
typeof item.price !== "number"
) {
throw new Error("Invalid product data");
}
return {
id: item.id,
name: item.name,
price: item.price
};
});
}
This example performs runtime checks before returning the data as Product[].
Important: Type Assertions Don't Validate Data
You might see code like this:
const products = (await response.json()) as Product[];
This only tells TypeScript to trust you.
It does not inspect the response or verify that the properties exist.
For larger applications, a runtime validation library such as Zod can make this process more convenient.
Example:
import { z } from "zod";
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number()
});
type Product = z.infer<typeof ProductSchema>;
async function fetchProduct(): Promise<Product> {
const response = await fetch("/api/product/1");
if (!response.ok) {
throw new Error("Failed to fetch product");
}
const data: unknown = await response.json();
return ProductSchema.parse(data);
}
Now the same schema provides both runtime validation and a TypeScript type.
This is especially useful when working with external APIs, forms, or user-generated data.
13. Understanding Generics in React
Generics allow you to create reusable functions and components that work with different types while preserving type safety.
Consider a simple function:
function identity<T>(value: T): T {
return value;
}
const numberValue = identity(10);
const stringValue = identity("Hello");
TypeScript understands the type of each returned value.
Generic List Component
Imagine building a list component that can display users, products, or any other data type.
Instead of creating a separate component for each type, use a generic component.
import type { ReactNode } from "react";
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => ReactNode;
};
function List<T>({
items,
getKey,
renderItem
}: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
Use it with products:
type Product = {
id: number;
name: string;
price: number;
};
const products: Product[] = [
{ id: 1, name: "Laptop", price: 1200 },
{ id: 2, name: "Phone", price: 800 }
];
function ProductList() {
return (
<List
items={products}
getKey={(product) => product.id}
renderItem={(product) => (
<span>
{product.name} — ${product.price}
</span>
)}
/>
);
}
TypeScript infers T as Product.
The renderItem callback therefore knows that its argument has id, name, and price properties.
Generic Constraints
Sometimes a generic needs to have a particular property.
Use extends to define a constraint:
function getId<T extends { id: number }>(item: T): number {
return item.id;
}
Now the function accepts any object that has a numeric id.
Generics are especially useful for reusable UI components, data utilities, Hooks, and API helpers.
14. Typing Native HTML Props
Sometimes you want a custom React component to support all the normal HTML attributes of an element.
For example, a custom button may need to support disabled, type, aria-label, and onClick.
You can reuse React's built-in prop types:
import type { ComponentProps } from "react";
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "secondary";
};
function Button({
variant = "primary",
children,
...props
}: ButtonProps) {
return (
<button
className={`button button-${variant}`}
{...props}
>
{children}
</button>
);
}
Usage:
<Button
variant="primary"
disabled={false}
onClick={() => console.log("Clicked")}
>
Save Changes
</Button>
This approach avoids manually recreating every native button property.
You can use ComponentProps<"input">, ComponentProps<"a">, and other HTML element types in the same way.
15. Discriminated Unions for Better Component APIs
Sometimes a component supports multiple modes, and each mode requires different props.
A discriminated union can make invalid combinations impossible to represent.
Consider a button that can behave as either a link or an action button:
type ButtonProps =
| {
variant: "link";
href: string;
onClick?: never;
}
| {
variant: "button";
onClick: () => void;
href?: never;
};
Implement the component:
function ActionButton(props: ButtonProps) {
if (props.variant === "link") {
return <a href={props.href}>Open Page</a>;
}
return (
<button onClick={props.onClick}>
Perform Action
</button>
);
}
Valid usage:
<ActionButton variant="link" href="/about" />
<ActionButton
variant="button"
onClick={() => console.log("Saved")}
/>
Invalid usage:
<ActionButton
variant="link"
href="/about"
onClick={() => console.log("Clicked")}
/>
TypeScript will report an error because the link variant doesn't accept an onClick property.
This pattern is useful for buttons, dialogs, notifications, input components, and other UI elements with multiple modes.
16. Building a Small Type-Safe Product Interface
Let's combine several concepts into a practical example.
We'll create:
- A Product type
- A typed ProductCard component
- A typed product list
- A callback for adding products to a cart
Step 1: Define the Product Type
export type Product = {
id: number;
name: string;
price: number;
category: "electronics" | "clothing" | "books";
inStock: boolean;
};
Step 2: Create ProductCard
import type { Product } from "./types";
type ProductCardProps = {
product: Product;
onAddToCart: (product: Product) => void;
};
function ProductCard({
product,
onAddToCart
}: ProductCardProps) {
return (
<article className="product-card">
<h2>{product.name}</h2>
<p>Category: {product.category}</p>
<p>Price: ${product.price}</p>
{product.inStock ? (
<button onClick={() => onAddToCart(product)}>
Add to Cart
</button>
) : (
<p>Out of stock</p>
)}
</article>
);
}
export default ProductCard;
Step 3: Create the Product List
import { useState } from "react";
import type { Product } from "./types";
import ProductCard from "./ProductCard";
const initialProducts: Product[] = [
{
id: 1,
name: "Laptop",
price: 1200,
category: "electronics",
inStock: true
},
{
id: 2,
name: "T-Shirt",
price: 25,
category: "clothing",
inStock: true
},
{
id: 3,
name: "React Guide",
price: 40,
category: "books",
inStock: false
}
];
function ProductList() {
const [cart, setCart] = useState<Product[]>([]);
const addToCart = (product: Product) => {
setCart((currentCart) => [
...currentCart,
product
]);
};
return (
<main>
<h1>Products</h1>
<p>Cart items: {cart.length}</p>
<div className="product-grid">
{initialProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={addToCart}
/>
))}
</div>
</main>
);
}
export default ProductList;
Notice how TypeScript protects the entire data flow:
- Every product must match the
Producttype. -
ProductCardrequires a valid product. - The callback must receive a
Product. - The cart can only contain products.
This makes the application easier to maintain as it grows.
17. Common TypeScript Mistakes in React
Even experienced developers can misuse TypeScript.
Here are some common mistakes to avoid.
Mistake 1: Using any Everywhere
Avoid:
function UserCard({ user }: any) {
return <h2>{user.name}</h2>;
}
Prefer:
type User = {
id: number;
name: string;
};
type UserCardProps = {
user: User;
};
function UserCard({ user }: UserCardProps) {
return <h2>{user.name}</h2>;
}
Mistake 2: Overusing Type Assertions
Avoid:
const user = responseData as User;
This doesn't validate the data.
Prefer runtime validation when the data comes from an untrusted source.
Mistake 3: Using the Non-Null Assertion Everywhere
Avoid:
inputRef.current!.focus();
The ! tells TypeScript to assume the value is not null.
If that assumption is wrong, the application can still crash.
Prefer:
inputRef.current?.focus();
Mistake 4: Ignoring Nullable State
Avoid:
const [user, setUser] = useState<User>(null as any);
Prefer:
const [user, setUser] = useState<User | null>(null);
Mistake 5: Manually Typing Every Variable
You don't need to annotate every value.
const count: number = 10;
const name: string = "Alex";
Can usually be simplified to:
const count = 10;
const name = "Alex";
Let TypeScript infer obvious types.
Mistake 6: Confusing TypeScript Types with Runtime Checks
This:
type User = {
name: string;
};
Doesn't guarantee that a server response contains a valid name.
Types help during development. Runtime validation protects your application from unexpected data.
18. Best Practices for React + TypeScript
Here are some practical recommendations:
- Enable strict TypeScript checking.
- Define reusable types for important domain models.
- Prefer precise types over
any. - Use
unknownfor untrusted values until they're validated. - Let TypeScript infer types when the intent is obvious.
- Use
React.ReactNodefor renderable children. - Type event handlers accurately.
- Use discriminated unions for components with multiple variants.
- Keep API validation separate from UI rendering.
- Use generics when they improve reuse without making code unnecessarily complex.
- Avoid unnecessary type assertions and non-null assertions.
- Keep component props focused and easy to understand.
Suggested Project Structure
As your application grows, organizing types and components becomes increasingly important.
src/
├── components/
│ ├── Button.tsx
│ ├── ProductCard.tsx
│ └── ProductList.tsx
├── hooks/
│ └── useProducts.ts
├── context/
│ └── ThemeContext.tsx
├── services/
│ └── productApi.ts
├── types/
│ ├── product.ts
│ └── user.ts
├── App.tsx
└── main.tsx
You don't need to create a separate type file for every component. Keep small, component-specific prop types near the component itself.
Move shared domain types into dedicated files when multiple parts of the application need them.
19. TypeScript React Checklist
Before considering your React TypeScript code ready, check the following:
- [ ] Strict mode is enabled.
- [ ] Component props have meaningful types.
- [ ] State types represent all possible values.
- [ ] Nullable values are handled safely.
- [ ] Event handlers use appropriate event types.
- [ ] Custom Hooks expose clear return values.
- [ ] Context values are checked safely.
- [ ] API responses are validated at runtime.
- [ ]
anyis avoided unless there's a strong reason. - [ ] Type assertions are used carefully.
- [ ] Reusable components use generics when appropriate.
- [ ] Components have clear and predictable contracts.
Final Thoughts
TypeScript doesn't replace good React architecture, testing, or runtime error handling.
What it does is make your code easier to reason about.
When you combine React's component model with TypeScript's type system, you get applications that are more predictable, easier to refactor, and simpler to maintain.
You don't need to use advanced generics or complicated utility types everywhere.
Start with component props, state, event handlers, and API models. As your application grows, introduce more advanced patterns where they provide real value.
The goal isn't to write the most complicated types.
The goal is to make incorrect code harder to write.
What's Next?
In Part 10: React Router — Routing, Nested Routes & Navigation, we'll explore how to build multi-page React applications without losing the benefits of a component-based architecture.
We'll cover:
- Installing and configuring React Router
- Creating routes and navigation links
- Dynamic URL parameters
- Nested routes and layouts
- Route parameters and query strings
- Protected routes
- Programmatic navigation
- Handling 404 pages
Stay tuned, and keep building! 🚀
Top comments (0)