React with TypeScript
Integrating TypeScript with React
TypeScript adds static type checking to JavaScript, helping catch errors at compile time and improving code quality. In this lesson, we'll learn how to use TypeScript effectively with React to build more maintainable and robust applications.
Why TypeScript with React?
TypeScript provides several benefits for React development:
- Type Safety: Catch errors during development instead of runtime
- Better IntelliSense: Enhanced autocomplete and documentation
- Refactoring Confidence: Change code safely with type checking
- Self-Documenting Code: Types serve as inline documentation
- Improved Collaboration: Clear contracts between components
Setting Up TypeScript with React
Create a new React app with TypeScript:
# New project with TypeScript
npx create-react-app my-app --template typescript
# Add TypeScript to existing project
npm install --save typescript @types/node @types/react @types/react-dom
npm install --save-dev @types/jest
# Generate tsconfig.json
npx tsc --init
# Create new Vite + React + TypeScript project
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Typing Function Components
Basic patterns for typing React function components:
// Explicit return type (optional but recommended)
function Greeting(): JSX.Element {
return <h1>Hello, World!</h1>;
}
// Arrow function with explicit type
const Greeting: React.FC = () => {
return <h1>Hello, World!</h1>;
};
// Most concise (type inference)
function Greeting() {
return <h1>Hello, World!</h1>;
}
React.FC or React.FunctionComponent. Use regular function syntax and let TypeScript infer the return type or explicitly type it as JSX.Element.
Typing Props
Define prop types using interfaces or type aliases:
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean; // Optional prop
variant?: 'primary' | 'secondary'; // Union type
}
function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
}
// Usage
<Button label="Click Me" onClick={() => console.log('Clicked')} />
<Button label="Submit" onClick={handleSubmit} variant="secondary" />
interface CardProps {
title: string;
children: React.ReactNode; // Accepts any valid React children
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Usage
<Card title="My Card">
<p>This is the card content</p>
<button>Action</button>
</Card>
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
interface UserListProps {
users: User[];
onUserClick: (user: User) => void;
loading?: boolean;
error?: string | null;
renderEmpty?: () => React.ReactNode;
}
function UserList({
users,
onUserClick,
loading = false,
error = null,
renderEmpty
}: UserListProps) {
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (users.length === 0) {
return renderEmpty ? <>{renderEmpty()}</> : <p>No users found</p>;
}
return (
<ul>
{users.map(user => (
<li key={user.id} onClick={() => onUserClick(user)}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}
{} as a type (e.g., children: {}). Use React.ReactNode for children, object for plain objects, or define specific interfaces.
Typing State
TypeScript can infer state types, but explicit typing helps with complex state:
import { useState } from 'react';
function Counter() {
// Type inferred as number
const [count, setCount] = useState(0);
// Explicit type (useful for complex types)
const [count, setCount] = useState<number>(0);
// String state
const [name, setName] = useState('John');
// Boolean state
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
interface FormData {
username: string;
email: string;
age: number;
}
function UserForm() {
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
age: 0
});
const handleChange = (field: keyof FormData, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
return (
<form>
<input
value={formData.username}
onChange={e => handleChange('username', e.target.value)}
/>
<input
type="email"
value={formData.email}
onChange={e => handleChange('email', e.target.value)}
/>
<input
type="number"
value={formData.age}
onChange={e => handleChange('age', parseInt(e.target.value))}
/>
</form>
);
}
type Status = 'idle' | 'loading' | 'success' | 'error';
interface DataState<T> {
status: Status;
data: T | null;
error: string | null;
}
function DataFetcher() {
const [state, setState] = useState<DataState<User[]>>({
status: 'idle',
data: null,
error: null
});
const fetchData = async () => {
setState({ status: 'loading', data: null, error: null });
try {
const response = await fetch('/api/users');
const data = await response.json();
setState({ status: 'success', data, error: null });
} catch (error) {
setState({
status: 'error',
data: null,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
return (
<div>
{state.status === 'loading' && <p>Loading...</p>}
{state.status === 'error' && <p>Error: {state.error}</p>}
{state.status === 'success' && state.data && (
<ul>
{state.data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
Typing Events
React event types in TypeScript:
import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react';
function EventExamples() {
// Input change
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
// Textarea change
const handleTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
console.log(e.target.value);
};
// Select change
const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
console.log(e.target.value);
};
// Form submit
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('Form submitted');
};
// Button click
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log('Button clicked', e.clientX, e.clientY);
};
// Keyboard events
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
console.log('Enter pressed');
}
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyPress={handleKeyPress} />
<textarea onChange={handleTextareaChange} />
<select onChange={handleSelectChange}>
<option>Option 1</option>
</select>
<button onClick={handleClick}>Submit</button>
</form>
);
}
EventType<HTMLElementType>. Let your IDE autocomplete suggest the correct types based on the element.
Generic Components
Create reusable components with generics:
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
emptyMessage?: string;
}
function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'No items found'
}: ListProps<T>) {
if (items.length === 0) {
return <p>{emptyMessage}</p>;
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Usage with different types
interface User {
id: number;
name: string;
}
interface Product {
id: string;
title: string;
price: number;
}
function App() {
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
const products: Product[] = [
{ id: 'p1', title: 'Laptop', price: 999 },
{ id: 'p2', title: 'Mouse', price: 29 }
];
return (
<>
<List
items={users}
keyExtractor={user => user.id}
renderItem={user => <span>{user.name}</span>}
/>
<List
items={products}
keyExtractor={product => product.id}
renderItem={product => (
<div>
{product.title} - ${product.price}
</div>
)}
/>
</>
);
}
interface Option<T> {
value: T;
label: string;
}
interface SelectProps<T> {
options: Option<T>[];
value: T;
onChange: (value: T) => void;
placeholder?: string;
}
function Select<T extends string | number>({
options,
value,
onChange,
placeholder = 'Select an option'
}: SelectProps<T>) {
return (
<select
value={value as string}
onChange={e => {
const selectedOption = options.find(
opt => String(opt.value) === e.target.value
);
if (selectedOption) {
onChange(selectedOption.value);
}
}}
>
<option value="">{placeholder}</option>
{options.map(option => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
);
}
// Usage
function App() {
const [selectedId, setSelectedId] = useState<number>(0);
const [selectedColor, setSelectedColor] = useState<string>('');
return (
<>
<Select
options={[
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' }
]}
value={selectedId}
onChange={setSelectedId}
/>
<Select
options={[
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' }
]}
value={selectedColor}
onChange={setSelectedColor}
/>
</>
);
}
Interfaces vs Type Aliases
Both can define component props, but have different use cases:
// Interfaces can be extended and merged
interface BaseProps {
id: string;
className?: string;
}
interface ButtonProps extends BaseProps {
label: string;
onClick: () => void;
}
// Declaration merging (useful for libraries)
interface Window {
myCustomProperty: string;
}
// Types are better for unions and complex types
type Status = 'idle' | 'loading' | 'success' | 'error';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ResponseData<T> = {
data: T;
status: number;
} | {
error: string;
status: number;
};
// Types can use mapped types
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
// Types can use conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
interface for object shapes and component props. Use type for unions, intersections, and complex type transformations.
Exercise 1: Todo App with TypeScript
Build a todo application with full TypeScript types:
- Define Todo interface with id, text, completed, createdAt
- Create TodoList component with proper prop types
- Type all event handlers correctly
- Use proper state types for todo list
- Implement add, toggle, delete, and filter functionality
Exercise 2: Generic Data Table
Create a reusable, generic data table component:
- Accept generic data type
- Define column configuration with type-safe accessors
- Implement sorting with proper types
- Add pagination with typed handlers
- Support custom cell renderers
Exercise 3: Form Builder
Build a type-safe form component:
- Create FormField interface with different field types
- Type form state and validation errors
- Implement type-safe form submission
- Add field-level and form-level validation
- Support different input types with proper typing