DEVELOPMENT

TypeScript with React complete guide: Components, hooks, and advanced patterns

Master TypeScript with React using comprehensive guide covering component typing, hooks patterns, and advanced techniques for building robust, type-safe applications with modern development workflows.

Vladimir Siedykh

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 options
  • jsx: "react-jsx" supports the new JSX transform
  • Path mapping with baseUrl and paths 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.

TypeScript React - FAQ & implementation guide

Yes, TypeScript provides significant benefits for React projects including better IDE support, compile-time error detection, improved refactoring capabilities, and better code documentation. The initial setup cost is minimal with modern tooling.

Define props using interfaces rather than types, use optional properties with ?, provide default values in destructuring, and consider using generic props for reusable components. Always export prop interfaces for better reusability.

Use interfaces for React component props as they are extendable and provide better error messages. Use types for union types, computed properties, and complex type manipulations. Interfaces are generally preferred for object shapes.

TypeScript usually infers hook types automatically. For useState, provide generic types for complex state. For useEffect, type async operations properly. For custom hooks, define clear input/output types and return type annotations.

Use specific event types like React.ChangeEvent<HTMLInputElement> for form events, React.MouseEvent for click events, and React.KeyboardEvent for keyboard events. Import event types from React namespace.

Organize types in dedicated files, use barrel exports sparingly, create reusable type utilities, define strict tsconfig settings, and use consistent naming conventions. Separate business logic types from component prop types.

Stay ahead with expert insights

Get practical tips on web design, business growth, SEO strategies, and development best practices delivered to your inbox.