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 decrem...
