Table of Contents
- Why TypeScript with React?
- Setting Up TypeScript in a React Project
- Basic TypeScript Types for React
- Typing React Components
- Handling Props and State
- Event Handling with Types
- Advanced Patterns: Generics and Utility Types
- TypeScript with React Hooks
- TypeScript and State Management
- Best Practices for Type Safety
- Common Pitfalls and How to Avoid Them
- Conclusion
- References
Why TypeScript with React?
React’s component-based architecture and TypeScript’s static typing are a match made in heaven. Here’s why you should use them together:
- Early Error Detection: TypeScript catches type mismatches during development, not at runtime, reducing bugs in production.
- Enhanced Developer Experience: IDEs like VS Code provide IntelliSense, autocompletion, and inline type hints, speeding up development.
- Self-Documenting Code: Types serve as living documentation, making it easier for teams to understand component props, state, and data flows.
- Refactoring Confidence: Renaming a prop or modifying a data structure? TypeScript flags breaking changes instantly, reducing refactoring risk.
Setting Up TypeScript in a React Project
Creating a New Project
The easiest way to start is with a TypeScript-enabled React template:
Using Create React App (CRA):
npx create-react-app my-ts-react-app --template typescript
Using Vite (faster, more modern):
npm create vite@latest my-ts-react-app -- --template react-ts
cd my-ts-react-app
npm install
Key tsconfig.json Settings
TypeScript behavior is controlled by tsconfig.json. For React, focus on these critical settings:
{
"compilerOptions": {
"target": "ES6", // Compile to modern JS
"lib": ["DOM", "ES6"], // Include DOM and ES6 libraries
"jsx": "react-jsx", // Support JSX (React 17+ uses react-jsx)
"module": "ESNext", // Use modern module system
"strict": true, // Enable strict type-checking (critical for safety!)
"esModuleInterop": true, // Compatibility with CommonJS modules
"skipLibCheck": true, // Skip type-checking for node_modules
"forceConsistentCasingInFileNames": true
},
"include": ["src"] // Files to type-check
}
Why strict: true? Enables rules like noImplicitAny (prevents untyped variables), strictNullChecks (requires handling null/undefined), and strictFunctionTypes (enforces function parameter variance). This ensures maximum type safety.
Basic TypeScript Types for React
Before diving into React-specific typing, let’s cover foundational TypeScript types with React-focused examples.
Primitive Types
TypeScript supports JavaScript primitives with explicit typing:
// String
const userName: string = "Alice";
// Number
const age: number = 30;
// Boolean
const isLoggedIn: boolean = true;
Arrays
Define arrays with Type[] or Array<Type>:
// List of user IDs (numbers)
const userIds: number[] = [1, 2, 3];
// List of product names (strings)
const productNames: Array<string> = ["Laptop", "Phone"];
Objects
Use interface or type to define object shapes (critical for React props/state):
// Interface (preferred for props/objects)
interface User {
id: number;
name: string;
email: string;
isVerified?: boolean; // Optional property (denoted with ?)
}
// Type alias (useful for unions/intersections)
type Product = {
id: string;
name: string;
price: number;
};
// Usage in React: Typing a user prop
const UserProfile = (user: User) => <div>{user.name}</div>;
Union and Intersection Types
- Union (
|): A value can be one of several types. - Intersection (
&): Combine multiple types into one.
// Union: Status can be "loading", "success", or "error"
type Status = "loading" | "success" | "error";
// Intersection: Combine User and Address
interface Address {
street: string;
city: string;
}
type UserWithAddress = User & Address; // { id: number; name: string; ...street, city }
Typing React Components
React components (especially functional components) rely heavily on TypeScript for props and state typing. Let’s break it down.
Functional Components and Props
Functional components are typed by defining their props. Use interface or type for props, and optionally React.FC (Functional Component) for the component itself.
Example: Basic Component with Props
import React from "react";
// Define props with an interface
interface GreetingProps {
name: string; // Required prop
message?: string; // Optional prop (with default)
}
// Option 1: Using React.FC (includes children by default)
const Greeting: React.FC<GreetingProps> = ({ name, message = "Hello" }) => {
return <h1>{`${message}, ${name}!`}</h1>;
};
// Option 2: Without React.FC (more flexible, no implicit children)
const Greeting = ({ name, message = "Hello" }: GreetingProps) => {
return <h1>{`${message}, ${name}!`}</h1>;
};
// Usage
<Greeting name="Alice" /> // ✅ Valid
<Greeting name="Bob" message="Hi" /> // ✅ Valid
<Greeting /> // ❌ Error: "name" is required
Note: React.FC adds an implicit children prop, but many prefer omitting it unless children are explicitly needed (to avoid accidental children usage).
Typing Children
To explicitly allow children, add a children prop:
interface CardProps {
title: string;
children: React.ReactNode; // Accepts any valid React node (elements, text, fragments)
}
const Card = ({ title, children }: CardProps) => {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-content">{children}</div>
</div>
);
};
// Usage
<Card title="Profile">
<p>Name: Alice</p>
<p>Age: 30</p>
</Card>
Handling Props and State
Typing State with useState
TypeScript infers state types from the initial value, but explicit typing is needed when the initial state is null/undefined or complex.
import { useState } from "react";
// Inferred type: string
const [name, setName] = useState("Alice");
// Explicit type (initial state is null)
const [email, setEmail] = useState<string | null>(null);
// Complex state (object)
interface FormState {
username: string;
password: string;
}
const [formState, setFormState] = useState<FormState>({
username: "",
password: "",
});
Typing useReducer
For complex state logic, useReducer pairs well with TypeScript. Define the state shape and action types:
import { useReducer } from "react";
// State interface
interface CounterState {
count: number;
}
// Action type (union of possible actions)
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "set"; payload: number };
// Reducer function
const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "set":
return { count: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
// Usage
const Counter = () => {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "set", payload: 10 })}>Set to 10</button>
</div>
);
};
Event Handling with Types
React uses synthetic events, and TypeScript provides specific types for them (e.g., React.MouseEvent, React.ChangeEvent).
Common Event Types
| Event | Type | Target Example |
|---|---|---|
| Click | React.MouseEvent<T> | HTMLButtonElement |
| Input Change | React.ChangeEvent<T> | HTMLInputElement |
| Form Submit | React.FormEvent<T> | HTMLFormElement |
Example: Typing an Input Handler
const InputWithLabel = () => {
const [value, setValue] = useState("");
// Handle input change: e is React.ChangeEvent<HTMLInputElement>
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value); // ✅ Type-safe access to e.target.value
};
return (
<div>
<label>Name:</label>
<input type="text" value={value} onChange={handleChange} />
</div>
);
};
Example: Typing a Button Click
const Button = () => {
// Handle click: e is React.MouseEvent<HTMLButtonElement>
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("Button clicked!", e.currentTarget);
};
return <button onClick={handleClick}>Click Me</button>;
};
Advanced Patterns: Generics and Utility Types
Generics for Reusable Components
Generics let you create components that work with multiple types. For example, a reusable List component:
// Generic component: works with any item type
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
const List = <T,>({ items, renderItem }: ListProps<T>) => {
return <ul>{items.map((item, index) => <li key={index}>{renderItem(item)}</li>)}</ul>;
};
// Usage with numbers
<List items={[1, 2, 3]} renderItem={(num) => <span>{num * 2}</span>} />
// Usage with User objects
<List
items={[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]}
renderItem={(user) => <span>{user.name}</span>}
/>
Utility Types
TypeScript provides built-in utility types to transform existing types. Useful in React for props manipulation:
Partial<T>: Make all properties of T optional
interface User {
name: string;
age: number;
}
type PartialUser = Partial<User>; // { name?: string; age?: number }
// Use case: Updating user info (only send changed fields)
const updateUser = (user: User, changes: Partial<User>): User => ({ ...user, ...changes });
Omit<T, K>: Remove properties K from T
interface Product {
id: string;
name: string;
price: number;
createdAt: Date;
}
// Omit createdAt (not needed for a "create product" form)
type CreateProduct = Omit<Product, "id" | "createdAt">; // { name: string; price: number }
TypeScript with React Hooks
Most React hooks work seamlessly with TypeScript, with minimal extra typing.
useRef
For DOM refs, specify the element type:
import { useRef, useEffect } from "react";
const AutoFocusInput = () => {
// Ref for an input element (initial value: null)
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // ✅ Safe access with optional chaining
}, []);
return <input ref={inputRef} type="text" />;
};
useContext
Type the context value when creating it:
import { createContext, useContext, ReactNode } from "react";
// Define context type
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
// Create context with default value (or use undefined and check in provider)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Provider component
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = () => setTheme(prev => prev === "light" ? "dark" : "light");
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
// Custom hook to use the theme
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
TypeScript and State Management
Redux Toolkit (RTK)
RTK is designed for TypeScript, with built-in type inference. Define state interfaces and use createSlice:
import { createSlice, configureStore } from "@reduxjs/toolkit";
import { useSelector, useDispatch } from "react-redux";
// State interface
interface CounterState {
value: number;
}
// Initial state
const initialState: CounterState = { value: 0 };
// Slice with typed reducers/actions
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
},
});
// Store configuration
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
});
// Typed hooks (inferred from store)
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
// Usage in component
const Counter = () => {
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
return <button onClick={() => dispatch(counterSlice.actions.increment())}>{count}</button>;
};
Best Practices for Type Safety
- Enable
strict: true: Enforces strict type-checking (e.g.,noImplicitAny,strictNullChecks). - Prefer Interfaces for Props: Conventional in React for defining component props (e.g.,
interface ButtonProps). - Avoid
any:anydisables type-checking—useunknowninstead and narrow the type with type guards. - Type Narrowing: Use
typeof,instanceof, or custom type guards to handle union types safely:// Type guard for User const isUser = (value: unknown): value is User => { return typeof value === "object" && value !== null && "id" in value; }; const processData = (data: unknown) => { if (isUser(data)) { console.log(data.name); // ✅ Safe: data is now typed as User } }; - Document Types: Add JSDoc comments to interfaces/types for clarity:
/** User profile data from API */ interface User { /** Unique user ID */ id: number; /** Full name (first + last) */ name: string; }
Common Pitfalls and How to Avoid Them
- Overusing
React.FC: Leads to implicitchildrenprops. OmitReact.FCunless children are needed. - Ignoring
null/undefined: UsestrictNullChecksand handle optional values with?or??(nullish coalescing). - Mis Typing Event Targets: Always use
React.ChangeEvent<HTMLInputElement>instead ofanyfor events. - Forgetting Default Props: When using
React.FC, default props require extra typing—prefer destructuring with defaults:// Good: Destructure with default const Greeting = ({ name, message = "Hello" }: GreetingProps) => ...
Conclusion
TypeScript transforms React development by adding a layer of type safety that catches errors early, improves tooling, and makes code more maintainable. By mastering TypeScript’s basics (types, interfaces, generics) and React-specific patterns (typing components, props, events, hooks), you’ll build frontends that are robust, scalable, and a joy to work on.
Start small—convert a simple component to TypeScript, enable strict mode, and gradually adopt advanced patterns. The initial investment in learning will pay off with fewer bugs and faster development in the long run.