Learn how to leverage TypeScript with React to build robust, maintainable, and scalable applications. ๐
Project Setup ๐ฏ
First, let's create a new React project with TypeScript support.
# Create new project
npx create-react-app my-app --template typescript
# Or using Vite
npm create vite@latest my-app -- --template react-ts
Type-Safe Components ๐
Function Components
// src/components/Button.tsx
import { ReactNode, ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
children: ReactNode;
}
export const Button = ({
variant = 'primary',
children,
...props
}: ButtonProps) => {
return (
<button
className={`button button--${variant}`}
{...props}
>
{children}
</button>
);
};
Generic Components
// src/components/List.tsx
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
}
export function List<T>({ items, renderItem }: ListProps<T>) {
return (
<ul className="list">
{items.map((item, index) => (
<li key={index}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// Usage
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
<List
items={users}
renderItem={(user) => <span>{user.name}</span>}
/>;
Hooks with TypeScript ๐ฃ
useState
// src/hooks/useCounter.ts
import { useState } from 'react';
interface UseCounterProps {
initialValue?: number;
min?: number;
max?: number;
}
export function useCounter({
initialValue = 0,
min = -Infinity,
max = Infinity
}: UseCounterProps = {}) {
const [count, setCount] = useState(initialValue);
const increment = () => {
setCount(prev => Math.min(prev + 1, max));
};
const decrement = () => {
setCount(prev => Math.max(prev - 1, min));
};
return {
count,
increment,
decrement,
setCount
};
}
useReducer
// src/reducers/todoReducer.ts
interface Todo {
id: number;
text: string;
completed: boolean;
}
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'REMOVE_TODO'; payload: number };
interface TodoState {
todos: Todo[];
loading: boolean;
}
function todoReducer(
state: TodoState,
action: TodoAction
): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload,
completed: false
}
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'REMOVE_TODO':
return {
...state,
todos: state.todos.filter(
todo => todo.id !== action.payload
)
};
default:
return state;
}
}
Context with TypeScript ๐
// src/context/ThemeContext.tsx
import {
createContext,
useContext,
ReactNode,
useState
} from 'react';
interface Theme {
primaryColor: string;
backgroundColor: string;
}
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(
undefined
);
export function ThemeProvider({
children
}: {
children: ReactNode;
}) {
const [theme, setTheme] = useState<Theme>({
primaryColor: '#007bff',
backgroundColor: '#ffffff'
});
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
API Integration ๐
// src/api/types.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface ApiResponse<T> {
data: T;
message: string;
status: number;
}
// src/api/client.ts
import axios from 'axios';
import { User, ApiResponse } from './types';
const api = axios.create({
baseURL: 'https://api.example.com'
});
export async function getUser(id: number): Promise<User> {
const response = await api.get<ApiResponse<User>>(`/users/${id}`);
return response.data.data;
}
export async function updateUser(
id: number,
data: Partial<User>
): Promise<User> {
const response = await api.patch<ApiResponse<User>>(
`/users/${id}`,
data
);
return response.data.data;
}
Forms with TypeScript ๐
// src/components/UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18 years old')
});
type UserFormData = z.infer<typeof userSchema>;
interface UserFormProps {
onSubmit: (data: UserFormData) => void;
defaultValues?: Partial<UserFormData>;
}
export function UserForm({ onSubmit, defaultValues }: UserFormProps) {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name:</label>
<input id="name" {...register('name')} />
{errors.name && (
<span className="error">{errors.name.message}</span>
)}
</div>
<div>
<label htmlFor="email">Email:</label>
<input id="email" {...register('email')} />
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="age">Age:</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
/>
{errors.age && (
<span className="error">{errors.age.message}</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Custom Type Utilities ๐ ๏ธ
// src/types/utils.ts
// Make specific properties required
type RequiredProperties<T, K extends keyof T> =
T & Required<Pick<T, K>>;
// Make specific properties optional
type OptionalProperties<T, K extends keyof T> =
Omit<T, K> & Partial<Pick<T, K>>;
// Remove specific properties
type RemoveProperties<T, K extends keyof T> = Omit<T, K>;
// Make all properties nullable
type Nullable<T> = { [P in keyof T]: T[P] | null };
// Example usage
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
type UserWithRequiredAvatar = RequiredProperties<User, 'avatar'>;
type UserWithOptionalEmail = OptionalProperties<User, 'email'>;
type UserWithoutId = RemoveProperties<User, 'id'>;
type NullableUser = Nullable<User>;
Error Handling ๐จ
// src/utils/error.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public status: number
) {
super(message);
this.name = 'AppError';
}
}
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
// Error boundary component
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback: (error: Error) => ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = {
error: null
};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
Testing with TypeScript ๐งช
// src/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('applies variant classes correctly', () => {
const { container } = render(
<Button variant="secondary">Click me</Button>
);
expect(container.firstChild).toHaveClass('button--secondary');
});
});
Best Practices ๐
- Use strict TypeScript configuration
- Define proper interfaces and types
- Leverage type inference
- Use discriminated unions for state
- Implement proper error handling
- Write maintainable code
- Use proper type guards
- Implement proper testing
- Follow TypeScript best practices
- Optimize bundle size
Additional Resources
TypeScript provides powerful tools for building scalable React applications. Its type system helps catch errors early in development and improves the developer experience through better tooling support.