The False Security of any
Types Everywhere
I've seen countless React projects start without TypeScript, only to have developers desperately trying to add types months later when the codebase becomes unwieldy. The frustration is real - runtime errors that could have been caught at compile time, props being passed incorrectly, and hours spent debugging issues that TypeScript would have prevented.
The problem isn't that TypeScript is difficult to learn. The problem is that most developers try to retrofit TypeScript onto existing React patterns instead of learning how TypeScript and React work together from the ground up. You end up with any
types everywhere, overly complex interfaces, and a false sense of security.
This guide takes a different approach. Instead of treating TypeScript as an afterthought, we'll explore how proper typing enhances your React development workflow. You'll learn patterns that make your components more reliable, your hooks more reusable, and your entire application more maintainable.
By the end, you'll understand not just how to add types to React components, but how to leverage TypeScript's powerful features to build applications that are both robust and developer-friendly.
Setting up TypeScript with React properly
Before diving into component patterns, let's establish a solid TypeScript configuration that works well with React development workflows.
Modern project initialization
The fastest way to start a new TypeScript React project is with Create React App or Next.js, both of which provide excellent TypeScript support out of the box:
# Create React App with TypeScript
npx create-react-app my-app --template typescript
# Next.js with TypeScript
npx create-next-app@latest my-app --typescript
For existing projects, add TypeScript gradually:
npm install --save-dev typescript @types/react @types/react-dom
npm install --save-dev @types/node # If using Node.js features
Essential TypeScript configuration
A well-configured tsconfig.json
is crucial for a smooth TypeScript React experience:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "src",
"paths": {
"@/*": ["*"],
"@/components/*": ["components/*"],
"@/hooks/*": ["hooks/*"],
"@/types/*": ["types/*"]
}
},
"include": [
"src"
]
}
The key settings for React development:
strict: true
enables all strict type checking optionsjsx: "react-jsx"
supports the new JSX transform- Path mapping with
baseUrl
andpaths
for clean imports isolatedModules: true
ensures compatibility with bundlers
File naming conventions
Adopt consistent naming patterns that make TypeScript integration seamless:
src/
├── components/
│ ├── UserProfile/
│ │ ├── index.ts # Re-exports
│ │ ├── UserProfile.tsx # Component implementation
│ │ └── UserProfile.types.ts # Type definitions
├── hooks/
│ ├── useApi.ts
│ └── useLocalStorage.ts
├── types/
│ ├── api.types.ts
│ ├── user.types.ts
│ └── index.ts # Centralized type exports
└── utils/
├── api.ts
└── validation.ts
Typing React components effectively
React components with TypeScript require understanding several patterns for props, state, and component composition.
Basic component typing patterns
Start with simple functional components and build complexity gradually:
// Basic functional component with typed props
interface ButtonProps {
children: React.ReactNode;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
}
function Button({ children, onClick, variant = 'primary', disabled = false }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
// Alternative: Using React.FC (less preferred in modern React)
const Button: React.FC<ButtonProps> = ({ children, onClick, variant = 'primary', disabled = false }) => {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
Advanced prop patterns
Handle complex prop scenarios with proper typing:
// Conditional props with discriminated unions
type InputProps = {
label: string;
name: string;
} & (
| {
type: 'text' | 'email' | 'password';
value: string;
onChange: (value: string) => void;
}
| {
type: 'number';
value: number;
onChange: (value: number) => void;
}
| {
type: 'select';
value: string;
onChange: (value: string) => void;
options: Array<{ label: string; value: string }>;
}
);
function FormInput(props: InputProps) {
const { label, name, type } = props;
switch (type) {
case 'text':
case 'email':
case 'password':
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
/>
</div>
);
case 'number':
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type="number"
value={props.value}
onChange={(e) => props.onChange(Number(e.target.value))}
/>
</div>
);
case 'select':
return (
<div>
<label htmlFor={name}>{label}</label>
<select
id={name}
name={name}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
>
{props.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}
}
Component composition patterns
Create flexible, composable components with proper typing:
// Generic component for lists
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
loading?: boolean;
error?: string | null;
emptyMessage?: string;
}
function List<T>({
items,
renderItem,
keyExtractor,
loading = false,
error = null,
emptyMessage = 'No items found'
}: ListProps<T>) {
if (loading) {
return <div className="loading">Loading...</div>;
}
if (error) {
return <div className="error">Error: {error}</div>;
}
if (items.length === 0) {
return <div className="empty">{emptyMessage}</div>;
}
return (
<div className="list">
{items.map((item, index) => (
<div key={keyExtractor(item)} className="list-item">
{renderItem(item, index)}
</div>
))}
</div>
);
}
// Usage with proper type inference
interface User {
id: number;
name: string;
email: string;
}
function UserList({ users }: { users: User[] }) {
return (
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)}
emptyMessage="No users found"
/>
);
}
Mastering React hooks with TypeScript
TypeScript transforms how you work with React hooks, providing better intellisense, catching errors early, and making custom hooks more reusable. Building on the patterns we covered in our React hooks guide, let's explore TypeScript-specific techniques.
Typing useState patterns
Different useState patterns require different typing approaches:
// Simple state with type inference
const [count, setCount] = useState(0); // TypeScript infers number
const [name, setName] = useState(''); // TypeScript infers string
// Complex state with explicit typing
interface UserProfile {
id: number;
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
const [profile, setProfile] = useState<UserProfile | null>(null);
// State with union types
type LoadingState = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<LoadingState>('idle');
// Complex state with partial updates
interface FormState {
name: string;
email: string;
message: string;
errors: Record<string, string>;
}
const [form, setForm] = useState<FormState>({
name: '',
email: '',
message: '',
errors: {}
});
// Helper function for partial state updates
const updateForm = (updates: Partial<FormState>) => {
setForm(prev => ({ ...prev, ...updates }));
};
Advanced useEffect typing
TypeScript helps prevent common useEffect mistakes:
// Effect with proper cleanup typing
useEffect(() => {
let cancelled = false;
const fetchUserData = async (userId: number): Promise<void> => {
try {
const response = await fetch(`/api/users/${userId}`);
const userData: UserProfile = await response.json();
if (!cancelled) {
setProfile(userData);
}
} catch (error) {
if (!cancelled) {
console.error('Failed to fetch user:', error);
}
}
};
fetchUserData(userId);
// Cleanup function is properly typed
return () => {
cancelled = true;
};
}, [userId]);
// Effect with subscription cleanup
useEffect(() => {
const subscription = eventService.subscribe<NotificationEvent>(
'notification',
(event) => {
setNotifications(prev => [...prev, event.data]);
}
);
return () => subscription.unsubscribe();
}, []);
Creating type-safe custom hooks
Custom hooks become more powerful and reusable with proper TypeScript typing:
// Generic API hook with full type safety
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
interface UseApiOptions {
initialData?: any;
onSuccess?: (data: any) => void;
onError?: (error: Error) => void;
}
function useApi<T>(
url: string,
options: UseApiOptions = {}
): ApiState<T> & { refetch: () => Promise<void> } {
const [state, setState] = useState<ApiState<T>>({
data: options.initialData || null,
loading: true,
error: null
});
const fetchData = useCallback(async (): Promise<void> => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: T = await response.json();
setState({ data, loading: false, error: null });
options.onSuccess?.(data);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState({ data: null, loading: false, error: errorMessage });
options.onError?.(error as Error);
}
}, [url, options.onSuccess, options.onError]);
useEffect(() => {
fetchData();
}, [fetchData]);
return {
...state,
refetch: fetchData
};
}
// Usage with full type safety
interface Product {
id: number;
name: string;
price: number;
category: string;
}
function ProductDetails({ productId }: { productId: number }) {
const { data: product, loading, error, refetch } = useApi<Product>(
`/api/products/${productId}`,
{
onSuccess: (product) => {
console.log('Product loaded:', product.name);
},
onError: (error) => {
console.error('Failed to load product:', error);
}
}
);
if (loading) return <div>Loading product...</div>;
if (error) return <div>Error: {error}</div>;
if (!product) return <div>Product not found</div>;
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
<p>Category: {product.category}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
}
Form handling hooks with TypeScript
Building on form patterns, create type-safe form management:
// Generic form hook with validation
type ValidationRule<T> = (value: T) => string | null;
type ValidationRules<T> = {
[K in keyof T]?: ValidationRule<T[K]>;
};
interface UseFormOptions<T> {
initialValues: T;
validationRules?: ValidationRules<T>;
onSubmit?: (values: T) => Promise<void> | void;
}
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isSubmitting: boolean;
}
function useForm<T extends Record<string, any>>({
initialValues,
validationRules = {},
onSubmit
}: UseFormOptions<T>) {
const [state, setState] = useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false
});
const setValue = useCallback(<K extends keyof T>(
field: K,
value: T[K]
) => {
setState(prev => ({
...prev,
values: { ...prev.values, [field]: value },
errors: { ...prev.errors, [field]: undefined }
}));
}, []);
const setTouched = useCallback(<K extends keyof T>(field: K) => {
setState(prev => ({
...prev,
touched: { ...prev.touched, [field]: true }
}));
// Validate field when touched
const rule = validationRules[field];
if (rule) {
const error = rule(state.values[field]);
if (error) {
setState(prev => ({
...prev,
errors: { ...prev.errors, [field]: error }
}));
}
}
}, [validationRules, state.values]);
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
setState(prev => ({ ...prev, isSubmitting: true }));
// Validate all fields
const errors: Partial<Record<keyof T, string>> = {};
Object.keys(validationRules).forEach(field => {
const rule = validationRules[field as keyof T];
if (rule) {
const error = rule(state.values[field as keyof T]);
if (error) {
errors[field as keyof T] = error;
}
}
});
if (Object.keys(errors).length > 0) {
setState(prev => ({ ...prev, errors, isSubmitting: false }));
return;
}
try {
await onSubmit?.(state.values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setState(prev => ({ ...prev, isSubmitting: false }));
}
}, [state.values, validationRules, onSubmit]);
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isSubmitting: state.isSubmitting,
setValue,
setTouched,
handleSubmit
};
}
// Usage with full type safety
interface ContactForm {
name: string;
email: string;
message: string;
}
function ContactPage() {
const {
values,
errors,
touched,
isSubmitting,
setValue,
setTouched,
handleSubmit
} = useForm<ContactForm>({
initialValues: {
name: '',
email: '',
message: ''
},
validationRules: {
name: (value) => value.length < 2 ? 'Name must be at least 2 characters' : null,
email: (value) => !/\S+@\S+\.\S+/.test(value) ? 'Invalid email format' : null,
message: (value) => value.length < 10 ? 'Message must be at least 10 characters' : null
},
onSubmit: async (formData) => {
await submitContactForm(formData);
}
});
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
value={values.name}
onChange={(e) => setValue('name', e.target.value)}
onBlur={() => setTouched('name')}
placeholder="Your Name"
/>
{touched.name && errors.name && (
<span className="error">{errors.name}</span>
)}
</div>
<div>
<input
type="email"
value={values.email}
onChange={(e) => setValue('email', e.target.value)}
onBlur={() => setTouched('email')}
placeholder="Your Email"
/>
{touched.email && errors.email && (
<span className="error">{errors.email}</span>
)}
</div>
<div>
<textarea
value={values.message}
onChange={(e) => setValue('message', e.target.value)}
onBlur={() => setTouched('message')}
placeholder="Your Message"
rows={4}
/>
{touched.message && errors.message && (
<span className="error">{errors.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Advanced TypeScript patterns for React
As your React applications grow more complex, these advanced TypeScript patterns help maintain type safety while providing flexibility.
Generic components and render props
Create highly reusable components with generic typing:
// Generic data table component
interface Column<T> {
key: keyof T;
header: string;
render?: (value: T[keyof T], item: T) => React.ReactNode;
sortable?: boolean;
width?: string;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (item: T) => void;
loading?: boolean;
sortBy?: keyof T;
sortDirection?: 'asc' | 'desc';
onSort?: (key: keyof T, direction: 'asc' | 'desc') => void;
}
function DataTable<T extends Record<string, any>>({
data,
columns,
onRowClick,
loading = false,
sortBy,
sortDirection,
onSort
}: DataTableProps<T>) {
if (loading) {
return <div className="table-loading">Loading...</div>;
}
return (
<table className="data-table">
<thead>
<tr>
{columns.map(column => (
<th
key={String(column.key)}
style={{ width: column.width }}
className={column.sortable ? 'sortable' : ''}
onClick={() => {
if (column.sortable && onSort) {
const newDirection = sortBy === column.key && sortDirection === 'asc'
? 'desc'
: 'asc';
onSort(column.key, newDirection);
}
}}
>
{column.header}
{sortBy === column.key && (
<span className="sort-indicator">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr
key={index}
onClick={() => onRowClick?.(item)}
className={onRowClick ? 'clickable' : ''}
>
{columns.map(column => (
<td key={String(column.key)}>
{column.render
? column.render(item[column.key], item)
: String(item[column.key])
}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// Usage with full type safety
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'moderator';
createdAt: string;
}
function UsersPage() {
const { data: users, loading } = useApi<User[]>('/api/users');
const columns: Column<User>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
width: '200px'
},
{
key: 'email',
header: 'Email',
sortable: true,
width: '250px'
},
{
key: 'role',
header: 'Role',
render: (role) => (
<span className={`role-badge role-${role}`}>
{role.toUpperCase()}
</span>
),
width: '100px'
},
{
key: 'createdAt',
header: 'Created',
render: (date) => new Date(date).toLocaleDateString(),
sortable: true,
width: '120px'
}
];
return (
<DataTable
data={users || []}
columns={columns}
loading={loading}
onRowClick={(user) => {
console.log('Selected user:', user.name);
}}
/>
);
}
Higher-order components with TypeScript
Create reusable behavior patterns with proper typing:
// Generic HOC for loading states
interface WithLoadingProps {
loading: boolean;
}
function withLoading<P extends object>(
Component: React.ComponentType<P>,
LoadingComponent: React.ComponentType = () => <div>Loading...</div>
) {
return function WithLoadingComponent(props: P & WithLoadingProps) {
const { loading, ...restProps } = props;
if (loading) {
return <LoadingComponent />;
}
return <Component {...(restProps as P)} />;
};
}
// HOC for error boundaries
interface WithErrorBoundaryState {
hasError: boolean;
error?: Error;
}
function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
ErrorComponent: React.ComponentType<{ error: Error; retry: () => void }> =
({ error, retry }) => (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={retry}>Try Again</button>
</div>
)
) {
return class WithErrorBoundaryComponent extends React.Component<P, WithErrorBoundaryState> {
constructor(props: P) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): WithErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError && this.state.error) {
return <ErrorComponent error={this.state.error} retry={this.handleRetry} />;
}
return <Component {...this.props} />;
}
};
}
// Compose HOCs with proper typing
const EnhancedUserList = withErrorBoundary(
withLoading(UserList)
);
function App() {
const { data: users, loading, error } = useApi<User[]>('/api/users');
return (
<EnhancedUserList
users={users || []}
loading={loading}
/>
);
}
Utility types for React patterns
Leverage TypeScript's utility types for common React patterns:
// Extract component props type
type ButtonProps = React.ComponentProps<'button'>;
type InputProps = React.ComponentProps<'input'>;
// Make certain props required
interface BaseModalProps {
title?: string;
content?: React.ReactNode;
onClose?: () => void;
}
type RequiredModalProps = Required<Pick<BaseModalProps, 'title' | 'onClose'>> &
Omit<BaseModalProps, 'title' | 'onClose'>;
// Create variants of existing types
interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
type SuccessResponse<T> = Required<ApiResponse<T>> & { status: 'success' };
type ErrorResponse = Omit<ApiResponse<never>, 'data'> & {
status: 'error';
message: string;
};
// Conditional types for component variants
type ConditionalProps<T extends 'button' | 'link'> = T extends 'button'
? {
as: 'button';
onClick: () => void;
href?: never;
}
: {
as: 'link';
href: string;
onClick?: never;
};
type ActionProps<T extends 'button' | 'link'> = {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
} & ConditionalProps<T>;
function Action<T extends 'button' | 'link'>(props: ActionProps<T>) {
if (props.as === 'button') {
return (
<button
className={`action action-${props.variant || 'primary'}`}
onClick={props.onClick}
>
{props.children}
</button>
);
}
return (
<a
href={props.href}
className={`action action-${props.variant || 'primary'}`}
>
{props.children}
</a>
);
}
// Usage with type safety
function Navigation() {
return (
<nav>
<Action as="link" href="/home">Home</Action>
<Action as="button" onClick={() => console.log('clicked')}>
Click Me
</Action>
</nav>
);
}
Integration with Next.js and modern tooling
TypeScript integrates seamlessly with modern React frameworks like Next.js, which we covered extensively in our Next.js 15 tutorial. Let's explore framework-specific patterns.
Next.js API routes with TypeScript
Type-safe API development enhances both development experience and runtime reliability:
// types/api.types.ts
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
export interface CreateUserRequest {
name: string;
email: string;
password: string;
}
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import type { User, ApiResponse } from '@/types/api.types';
interface ExtendedRequest extends NextApiRequest {
query: {
id: string;
};
}
export default async function handler(
req: ExtendedRequest,
res: NextApiResponse<ApiResponse<User>>
) {
const { id } = req.query;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return res.status(400).json({
success: false,
error: 'Invalid user ID'
});
}
switch (req.method) {
case 'GET':
try {
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
return res.status(200).json({
success: true,
data: user
});
} catch (error) {
return res.status(500).json({
success: false,
error: 'Failed to fetch user'
});
}
case 'PUT':
// Handle user updates
break;
case 'DELETE':
// Handle user deletion
break;
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
return res.status(405).json({
success: false,
error: `Method ${req.method} not allowed`
});
}
}
// Client-side API utilities with full type safety
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = '/api') {
this.baseUrl = baseUrl;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
const data: ApiResponse<T> = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
return data;
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Network error');
}
}
async getUser(id: number): Promise<User> {
const response = await this.request<User>(`/users/${id}`);
if (!response.success || !response.data) {
throw new Error(response.error || 'Failed to fetch user');
}
return response.data;
}
async createUser(userData: CreateUserRequest): Promise<User> {
const response = await this.request<User>('/users', {
method: 'POST',
body: JSON.stringify(userData),
});
if (!response.success || !response.data) {
throw new Error(response.error || 'Failed to create user');
}
return response.data;
}
}
export const apiClient = new ApiClient();
Server-side rendering with TypeScript
Enhance SSR and SSG with proper typing:
import type { GetServerSideProps, GetStaticProps, GetStaticPaths } from 'next';
import type { User } from '@/types/api.types';
// Static generation with TypeScript
interface UserProfileProps {
user: User;
}
export default function UserProfile({ user }: UserProfileProps) {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>Role: {user.role}</p>
</div>
);
}
export const getStaticProps: GetStaticProps<UserProfileProps> = async (context) => {
const { params } = context;
const userId = params?.id as string;
try {
const user = await apiClient.getUser(parseInt(userId, 10));
return {
props: {
user,
},
revalidate: 3600, // Revalidate every hour
};
} catch (error) {
return {
notFound: true,
};
}
};
export const getStaticPaths: GetStaticPaths = async () => {
// Get the paths we want to pre-render
const users = await getAllUsers();
const paths = users.map((user) => ({
params: { id: user.id.toString() },
}));
return {
paths,
fallback: 'blocking',
};
};
// Server-side rendering with TypeScript
interface DashboardProps {
users: User[];
totalCount: number;
}
export default function Dashboard({ users, totalCount }: DashboardProps) {
return (
<div>
<h1>Dashboard</h1>
<p>Total users: {totalCount}</p>
<UserList users={users} />
</div>
);
}
export const getServerSideProps: GetServerSideProps<DashboardProps> = async (context) => {
const { req, query } = context;
// Get user session from request
const session = await getSession(req);
if (!session) {
return {
redirect: {
destination: '/login',
permanent: false,
},
};
}
const page = parseInt(query.page as string) || 1;
const limit = 10;
try {
const [users, totalCount] = await Promise.all([
getUsers({ page, limit }),
getUserCount(),
]);
return {
props: {
users,
totalCount,
},
};
} catch (error) {
return {
props: {
users: [],
totalCount: 0,
},
};
}
};
Error handling and debugging strategies
TypeScript helps catch many errors at compile time, but runtime error handling remains crucial for production applications.
Type-safe error handling patterns
Create robust error handling systems with proper typing:
// Error types for different scenarios
abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
}
class ValidationError extends AppError {
readonly code = 'VALIDATION_ERROR';
readonly statusCode = 400;
constructor(
message: string,
public readonly field?: string
) {
super(message);
this.name = 'ValidationError';
}
}
class NotFoundError extends AppError {
readonly code = 'NOT_FOUND';
readonly statusCode = 404;
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
}
class NetworkError extends AppError {
readonly code = 'NETWORK_ERROR';
readonly statusCode = 500;
constructor(message: string = 'Network request failed') {
super(message);
this.name = 'NetworkError';
}
}
// Error boundary with TypeScript
interface ErrorBoundaryState {
hasError: boolean;
error?: AppError;
}
class TypedErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{}>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
// Check if it's our custom error type
if (error instanceof AppError) {
return { hasError: true, error };
}
// Convert generic errors to AppError
const appError = new NetworkError(error.message);
return { hasError: true, error: appError };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error to monitoring service
console.error('Error caught by boundary:', {
error: {
name: error.name,
message: error.message,
stack: error.stack,
code: error instanceof AppError ? error.code : 'UNKNOWN',
},
errorInfo,
});
}
render() {
if (this.state.hasError && this.state.error) {
return <ErrorDisplay error={this.state.error} />;
}
return this.props.children;
}
}
// Error display component
interface ErrorDisplayProps {
error: AppError;
}
function ErrorDisplay({ error }: ErrorDisplayProps) {
const getErrorAction = () => {
switch (error.code) {
case 'NOT_FOUND':
return <a href="/">Go Home</a>;
case 'VALIDATION_ERROR':
return <button onClick={() => window.location.reload()}>Try Again</button>;
default:
return <button onClick={() => window.location.reload()}>Reload Page</button>;
}
};
return (
<div className="error-display">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<p className="error-code">Error Code: {error.code}</p>
{getErrorAction()}
</div>
);
}
Development and debugging tools
Enhance your TypeScript React development with proper tooling:
// Development-only type checking component
interface TypeCheckProps<T> {
value: T;
expectedType: string;
children: React.ReactNode;
}
function TypeCheck<T>({ value, expectedType, children }: TypeCheckProps<T>) {
if (process.env.NODE_ENV === 'development') {
const actualType = typeof value;
const isArray = Array.isArray(value);
const displayType = isArray ? 'array' : actualType;
if (displayType !== expectedType) {
console.warn(
`Type mismatch: expected ${expectedType}, got ${displayType}`,
{ value }
);
}
}
return <>{children}</>;
}
// Props validation in development
function validateProps<T>(
props: T,
schema: Record<keyof T, (value: any) => boolean>
): void {
if (process.env.NODE_ENV !== 'development') return;
Object.keys(schema).forEach((key) => {
const validator = schema[key as keyof T];
const value = props[key as keyof T];
if (!validator(value)) {
console.error(`Invalid prop ${String(key)}:`, value);
}
});
}
// Usage in components
interface ProductCardProps {
product: {
id: number;
name: string;
price: number;
};
onAddToCart: (productId: number) => void;
}
function ProductCard({ product, onAddToCart }: ProductCardProps) {
// Validate props in development
validateProps({ product, onAddToCart }, {
product: (value) => value && typeof value.id === 'number',
onAddToCart: (value) => typeof value === 'function',
});
return (
<TypeCheck value={product} expectedType="object">
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
</TypeCheck>
);
}
Testing TypeScript React components
Testing TypeScript React components requires understanding how to work with types in testing environments and ensuring your tests provide meaningful type coverage.
Setting up TypeScript testing environment
Configure Jest and React Testing Library for TypeScript:
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
],
};
// src/setupTests.ts
import '@testing-library/jest-dom';
// Global test utilities
global.mockFetch = (data: any) => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => data,
});
};
Testing typed components and hooks
Write comprehensive tests that verify both functionality and type safety:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useApi } from '@/hooks/useApi';
import { ContactForm } from '@/components/ContactForm';
// Test custom hooks with TypeScript
describe('useApi hook', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
it('should handle successful API calls', async () => {
const mockData = { id: 1, name: 'Test User' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() =>
useApi<{ id: number; name: string }>('/api/user/1')
);
// Initial state
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
// Wait for API call to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
it('should handle API errors', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error('Network error')
);
const { result } = renderHook(() =>
useApi<any>('/api/user/1')
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error).toBe('Network error');
});
});
// Test form components with type safety
describe('ContactForm component', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
it('should validate form fields correctly', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
// Try to submit empty form
const submitButton = screen.getByRole('button', { name: /send/i });
await user.click(submitButton);
// Should show validation errors
expect(screen.getByText('Name must be at least 2 characters')).toBeInTheDocument();
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
expect(screen.getByText('Message must be at least 10 characters')).toBeInTheDocument();
// Should not call onSubmit
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should submit valid form data', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
// Fill form with valid data
await user.type(screen.getByPlaceholderText('Your Name'), 'John Doe');
await user.type(screen.getByPlaceholderText('Your Email'), 'john@example.com');
await user.type(
screen.getByPlaceholderText('Your Message'),
'This is a test message that is long enough to pass validation.'
);
// Submit form
await user.click(screen.getByRole('button', { name: /send/i }));
// Should call onSubmit with correct data
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'This is a test message that is long enough to pass validation.',
});
});
});
});
// Test components with complex prop types
interface MockUserProps {
user: {
id: number;
name: string;
role: 'admin' | 'user';
};
onEdit: (userId: number) => void;
}
function MockUserCard({ user, onEdit }: MockUserProps) {
return (
<div data-testid="user-card">
<h3>{user.name}</h3>
<p>Role: {user.role}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
</div>
);
}
describe('UserCard component', () => {
it('should render user information correctly', () => {
const mockUser = {
id: 1,
name: 'John Doe',
role: 'admin' as const,
};
const mockOnEdit = jest.fn();
render(<MockUserCard user={mockUser} onEdit={mockOnEdit} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Role: admin')).toBeInTheDocument();
fireEvent.click(screen.getByText('Edit'));
expect(mockOnEdit).toHaveBeenCalledWith(1);
});
});
Building scalable TypeScript React applications
For complex business applications, these patterns help maintain code quality and developer productivity as your team and codebase grow.
Enterprise-level architecture patterns
Structure large TypeScript React applications for maintainability:
src/
├── types/
│ ├── api/
│ │ ├── user.types.ts
│ │ ├── product.types.ts
│ │ └── index.ts
│ ├── ui/
│ │ ├── form.types.ts
│ │ ├── table.types.ts
│ │ └── index.ts
│ └── index.ts
├── services/
│ ├── api/
│ │ ├── user.service.ts
│ │ ├── product.service.ts
│ │ └── base.service.ts
│ └── auth/
│ ├── auth.service.ts
│ └── auth.types.ts
├── hooks/
│ ├── api/
│ │ ├── useUsers.ts
│ │ └── useProducts.ts
│ ├── ui/
│ │ ├── useModal.ts
│ │ └── usePagination.ts
│ └── business/
│ ├── useAuth.ts
│ └── usePermissions.ts
├── components/
│ ├── ui/ # Design system components
│ ├── business/ # Domain-specific components
│ └── layout/ # Layout components
└── pages/
├── users/
├── products/
└── dashboard/
Type-driven development workflow
Implement a development workflow that leverages TypeScript's benefits:
// 1. Start with types (contract-first development)
// types/user.types.ts
export interface User {
id: number;
name: string;
email: string;
role: UserRole;
profile: UserProfile;
permissions: Permission[];
}
export interface UserRole {
id: number;
name: string;
level: number;
}
export interface UserProfile {
avatar?: string;
bio?: string;
location?: string;
website?: string;
}
export interface Permission {
resource: string;
action: 'read' | 'write' | 'delete' | 'admin';
}
// 2. Create service layer with type contracts
// services/user.service.ts
export class UserService {
async getUser(id: number): Promise<User> {
const response = await this.apiClient.get<User>(`/users/${id}`);
return response.data;
}
async updateUser(id: number, updates: Partial<User>): Promise<User> {
const response = await this.apiClient.put<User>(`/users/${id}`, updates);
return response.data;
}
async searchUsers(query: UserSearchQuery): Promise<UserSearchResult> {
const response = await this.apiClient.post<UserSearchResult>('/users/search', query);
return response.data;
}
}
// 3. Build hooks that consume services
// hooks/useUsers.ts
export function useUsers(searchParams: UserSearchQuery) {
return useQuery({
queryKey: ['users', searchParams],
queryFn: () => userService.searchUsers(searchParams),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => userService.getUser(userId),
enabled: !!userId,
});
}
// 4. Create components that use hooks
// components/business/UserManagement.tsx
interface UserManagementProps {
initialFilters?: Partial<UserSearchQuery>;
}
export function UserManagement({ initialFilters = {} }: UserManagementProps) {
const [filters, setFilters] = useState<UserSearchQuery>({
query: '',
role: undefined,
page: 1,
limit: 20,
...initialFilters,
});
const { data: usersResult, loading, error } = useUsers(filters);
return (
<div className="user-management">
<UserFilters
filters={filters}
onFiltersChange={setFilters}
/>
<UserTable
users={usersResult?.users || []}
loading={loading}
error={error}
totalCount={usersResult?.totalCount || 0}
onPageChange={(page) => setFilters(prev => ({ ...prev, page }))}
/>
</div>
);
}
When you're ready to implement TypeScript in your React applications with confidence, our team specializes in building scalable web applications that leverage TypeScript's full potential. We help businesses establish type-safe development workflows that reduce bugs and improve developer productivity.
Advanced patterns for production applications
These patterns address real-world challenges in production TypeScript React applications.
Performance monitoring with TypeScript
Implement type-safe performance monitoring:
// Performance monitoring types
interface PerformanceMetric {
name: string;
value: number;
timestamp: number;
metadata?: Record<string, any>;
}
interface ComponentPerformanceData {
componentName: string;
renderCount: number;
averageRenderTime: number;
lastRenderTime: number;
slowRenders: number;
}
// Performance monitoring hook
function usePerformanceMonitoring(componentName: string) {
const performanceData = useRef<ComponentPerformanceData>({
componentName,
renderCount: 0,
averageRenderTime: 0,
lastRenderTime: 0,
slowRenders: 0,
});
const startTime = useRef<number>(0);
// Start timing before render
startTime.current = performance.now();
useEffect(() => {
const renderTime = performance.now() - startTime.current;
const data = performanceData.current;
data.renderCount += 1;
data.lastRenderTime = renderTime;
data.averageRenderTime =
(data.averageRenderTime * (data.renderCount - 1) + renderTime) / data.renderCount;
if (renderTime > 16) { // Slower than 60fps
data.slowRenders += 1;
}
// Report to analytics if performance is concerning
if (data.slowRenders > 5 || renderTime > 100) {
reportPerformanceIssue({
name: 'slow_component_render',
value: renderTime,
timestamp: Date.now(),
metadata: {
componentName,
renderCount: data.renderCount,
averageRenderTime: data.averageRenderTime,
},
});
}
});
return performanceData.current;
}
// Usage in components
function ExpensiveComponent({ data }: { data: any[] }) {
const performanceData = usePerformanceMonitoring('ExpensiveComponent');
const processedData = useMemo(() => {
return data.map(item => ({ ...item, processed: true }));
}, [data]);
// Component implementation...
return (
<div>
{process.env.NODE_ENV === 'development' && (
<div className="performance-info">
Renders: {performanceData.renderCount} |
Avg: {performanceData.averageRenderTime.toFixed(2)}ms |
Slow: {performanceData.slowRenders}
</div>
)}
{/* Component content */}
</div>
);
}
Feature flags and conditional rendering
Implement type-safe feature flagging:
// Feature flag types
interface FeatureFlags {
newDashboard: boolean;
advancedSearch: boolean;
betaFeatures: boolean;
paymentProcessorV2: boolean;
}
type FeatureFlagKey = keyof FeatureFlags;
// Feature flag context
interface FeatureFlagContextValue {
flags: FeatureFlags;
isEnabled: (flag: FeatureFlagKey) => boolean;
toggleFlag: (flag: FeatureFlagKey) => void;
}
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
// Feature flag provider
export function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
const [flags, setFlags] = useState<FeatureFlags>({
newDashboard: false,
advancedSearch: true,
betaFeatures: false,
paymentProcessorV2: false,
});
const isEnabled = useCallback((flag: FeatureFlagKey) => {
return flags[flag];
}, [flags]);
const toggleFlag = useCallback((flag: FeatureFlagKey) => {
setFlags(prev => ({ ...prev, [flag]: !prev[flag] }));
}, []);
return (
<FeatureFlagContext.Provider value={{ flags, isEnabled, toggleFlag }}>
{children}
</FeatureFlagContext.Provider>
);
}
// Feature flag hook
export function useFeatureFlags() {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within FeatureFlagProvider');
}
return context;
}
// Feature flag component wrapper
interface FeatureGateProps {
flag: FeatureFlagKey;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
const { isEnabled } = useFeatureFlags();
if (!isEnabled(flag)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Usage in components
function Dashboard() {
const { isEnabled } = useFeatureFlags();
return (
<div className="dashboard">
<FeatureGate flag="newDashboard" fallback={<LegacyDashboard />}>
<NewDashboard />
</FeatureGate>
<SearchSection />
<FeatureGate flag="advancedSearch">
<AdvancedFilters />
</FeatureGate>
{isEnabled('betaFeatures') && <BetaFeaturePanel />}
</div>
);
}
Building type-safe React applications that scale
TypeScript transforms React development from a runtime debugging exercise into a compile-time confidence builder. The patterns we've explored - from basic component typing to advanced generic patterns - create a foundation where your IDE becomes your pair programming partner, catching errors before they reach production.
The real power of TypeScript with React emerges when you embrace type-driven development. Starting with well-defined interfaces, building services that respect those contracts, and creating components that leverage TypeScript's inference capabilities results in applications that are not only more reliable but also more maintainable as your team grows.
Whether you're retrofitting TypeScript into an existing React codebase or starting fresh with type-safe patterns, the investment pays dividends through reduced debugging time, improved developer experience, and the confidence that comes from knowing your application's contracts are enforced at every level.
The techniques we've covered integrate seamlessly with modern development workflows, from our comprehensive Next.js authentication patterns to the advanced React hooks patterns that form the backbone of maintainable React applications.
Ready to implement these TypeScript patterns in your next React project? Start your project brief to discuss how we can help you build type-safe applications that scale with your business needs.