React.js Fundamentals

React with TypeScript

20 min Lesson 28 of 40

Integrating TypeScript with React

TypeScript adds static type checking to JavaScript, helping catch errors at compile time and improving code quality. In this lesson, we'll learn how to use TypeScript effectively with React to build more maintainable and robust applications.

Why TypeScript with React?

TypeScript provides several benefits for React development:

  • Type Safety: Catch errors during development instead of runtime
  • Better IntelliSense: Enhanced autocomplete and documentation
  • Refactoring Confidence: Change code safely with type checking
  • Self-Documenting Code: Types serve as inline documentation
  • Improved Collaboration: Clear contracts between components
TypeScript Version: This lesson assumes TypeScript 5.0+ and React 18+. Most concepts apply to earlier versions with minor syntax differences.

Setting Up TypeScript with React

Create a new React app with TypeScript:

Using Create React App:
# New project with TypeScript
npx create-react-app my-app --template typescript

# Add TypeScript to existing project
npm install --save typescript @types/node @types/react @types/react-dom
npm install --save-dev @types/jest

# Generate tsconfig.json
npx tsc --init
Using Vite:
# Create new Vite + React + TypeScript project
npm create vite@latest my-app -- --template react-ts

cd my-app
npm install
npm run dev
Basic tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Typing Function Components

Basic patterns for typing React function components:

Simple Component:
// Explicit return type (optional but recommended)
function Greeting(): JSX.Element {
  return <h1>Hello, World!</h1>;
}

// Arrow function with explicit type
const Greeting: React.FC = () => {
  return <h1>Hello, World!</h1>;
};

// Most concise (type inference)
function Greeting() {
  return <h1>Hello, World!</h1>;
}
Best Practice: Avoid using React.FC or React.FunctionComponent. Use regular function syntax and let TypeScript infer the return type or explicitly type it as JSX.Element.

Typing Props

Define prop types using interfaces or type aliases:

Props with Interface:
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean; // Optional prop
  variant?: 'primary' | 'secondary'; // Union type
}

function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {label}
    </button>
  );
}

// Usage
<Button label="Click Me" onClick={() => console.log('Clicked')} />
<Button label="Submit" onClick={handleSubmit} variant="secondary" />
Props with Children:
interface CardProps {
  title: string;
  children: React.ReactNode; // Accepts any valid React children
}

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="card-body">{children}</div>
    </div>
  );
}

// Usage
<Card title="My Card">
  <p>This is the card content</p>
  <button>Action</button>
</Card>
Complex Props Example:
interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}

interface UserListProps {
  users: User[];
  onUserClick: (user: User) => void;
  loading?: boolean;
  error?: string | null;
  renderEmpty?: () => React.ReactNode;
}

function UserList({
  users,
  onUserClick,
  loading = false,
  error = null,
  renderEmpty
}: UserListProps) {
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  if (users.length === 0) {
    return renderEmpty ? <>{renderEmpty()}</> : <p>No users found</p>;
  }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={() => onUserClick(user)}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
}
Common Mistake: Don't use {} as a type (e.g., children: {}). Use React.ReactNode for children, object for plain objects, or define specific interfaces.

Typing State

TypeScript can infer state types, but explicit typing helps with complex state:

Basic State:
import { useState } from 'react';

function Counter() {
  // Type inferred as number
  const [count, setCount] = useState(0);

  // Explicit type (useful for complex types)
  const [count, setCount] = useState<number>(0);

  // String state
  const [name, setName] = useState('John');

  // Boolean state
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
State with Object:
interface FormData {
  username: string;
  email: string;
  age: number;
}

function UserForm() {
  const [formData, setFormData] = useState<FormData>({
    username: '',
    email: '',
    age: 0
  });

  const handleChange = (field: keyof FormData, value: string | number) => {
    setFormData(prev => ({
      ...prev,
      [field]: value
    }));
  };

  return (
    <form>
      <input
        value={formData.username}
        onChange={e => handleChange('username', e.target.value)}
      />
      <input
        type="email"
        value={formData.email}
        onChange={e => handleChange('email', e.target.value)}
      />
      <input
        type="number"
        value={formData.age}
        onChange={e => handleChange('age', parseInt(e.target.value))}
      />
    </form>
  );
}
State with Union Types:
type Status = 'idle' | 'loading' | 'success' | 'error';

interface DataState<T> {
  status: Status;
  data: T | null;
  error: string | null;
}

function DataFetcher() {
  const [state, setState] = useState<DataState<User[]>>({
    status: 'idle',
    data: null,
    error: null
  });

  const fetchData = async () => {
    setState({ status: 'loading', data: null, error: null });

    try {
      const response = await fetch('/api/users');
      const data = await response.json();
      setState({ status: 'success', data, error: null });
    } catch (error) {
      setState({
        status: 'error',
        data: null,
        error: error instanceof Error ? error.message : 'Unknown error'
      });
    }
  };

  return (
    <div>
      {state.status === 'loading' && <p>Loading...</p>}
      {state.status === 'error' && <p>Error: {state.error}</p>}
      {state.status === 'success' && state.data && (
        <ul>
          {state.data.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Typing Events

React event types in TypeScript:

Common Event Types:
import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react';

function EventExamples() {
  // Input change
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  // Textarea change
  const handleTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
    console.log(e.target.value);
  };

  // Select change
  const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
    console.log(e.target.value);
  };

  // Form submit
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log('Form submitted');
  };

  // Button click
  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    console.log('Button clicked', e.clientX, e.clientY);
  };

  // Keyboard events
  const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      console.log('Enter pressed');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyPress={handleKeyPress} />
      <textarea onChange={handleTextareaChange} />
      <select onChange={handleSelectChange}>
        <option>Option 1</option>
      </select>
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}
Event Type Pattern: For event handlers, use EventType<HTMLElementType>. Let your IDE autocomplete suggest the correct types based on the element.

Generic Components

Create reusable components with generics:

Generic List Component:
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  emptyMessage?: string;
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyMessage = 'No items found'
}: ListProps<T>) {
  if (items.length === 0) {
    return <p>{emptyMessage}</p>;
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Usage with different types
interface User {
  id: number;
  name: string;
}

interface Product {
  id: string;
  title: string;
  price: number;
}

function App() {
  const users: User[] = [
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ];

  const products: Product[] = [
    { id: 'p1', title: 'Laptop', price: 999 },
    { id: 'p2', title: 'Mouse', price: 29 }
  ];

  return (
    <>
      <List
        items={users}
        keyExtractor={user => user.id}
        renderItem={user => <span>{user.name}</span>}
      />

      <List
        items={products}
        keyExtractor={product => product.id}
        renderItem={product => (
          <div>
            {product.title} - ${product.price}
          </div>
        )}
      />
    </>
  );
}
Generic Select Component:
interface Option<T> {
  value: T;
  label: string;
}

interface SelectProps<T> {
  options: Option<T>[];
  value: T;
  onChange: (value: T) => void;
  placeholder?: string;
}

function Select<T extends string | number>({
  options,
  value,
  onChange,
  placeholder = 'Select an option'
}: SelectProps<T>) {
  return (
    <select
      value={value as string}
      onChange={e => {
        const selectedOption = options.find(
          opt => String(opt.value) === e.target.value
        );
        if (selectedOption) {
          onChange(selectedOption.value);
        }
      }}
    >
      <option value="">{placeholder}</option>
      {options.map(option => (
        <option key={String(option.value)} value={String(option.value)}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

// Usage
function App() {
  const [selectedId, setSelectedId] = useState<number>(0);
  const [selectedColor, setSelectedColor] = useState<string>('');

  return (
    <>
      <Select
        options={[
          { value: 1, label: 'Option 1' },
          { value: 2, label: 'Option 2' }
        ]}
        value={selectedId}
        onChange={setSelectedId}
      />

      <Select
        options={[
          { value: 'red', label: 'Red' },
          { value: 'blue', label: 'Blue' }
        ]}
        value={selectedColor}
        onChange={setSelectedColor}
      />
    </>
  );
}

Interfaces vs Type Aliases

Both can define component props, but have different use cases:

When to Use Interface:
// Interfaces can be extended and merged
interface BaseProps {
  id: string;
  className?: string;
}

interface ButtonProps extends BaseProps {
  label: string;
  onClick: () => void;
}

// Declaration merging (useful for libraries)
interface Window {
  myCustomProperty: string;
}
When to Use Type:
// Types are better for unions and complex types
type Status = 'idle' | 'loading' | 'success' | 'error';

type ButtonVariant = 'primary' | 'secondary' | 'danger';

type ResponseData<T> = {
  data: T;
  status: number;
} | {
  error: string;
  status: number;
};

// Types can use mapped types
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Types can use conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
Convention: Use interface for object shapes and component props. Use type for unions, intersections, and complex type transformations.

Exercise 1: Todo App with TypeScript

Build a todo application with full TypeScript types:

  • Define Todo interface with id, text, completed, createdAt
  • Create TodoList component with proper prop types
  • Type all event handlers correctly
  • Use proper state types for todo list
  • Implement add, toggle, delete, and filter functionality

Exercise 2: Generic Data Table

Create a reusable, generic data table component:

  • Accept generic data type
  • Define column configuration with type-safe accessors
  • Implement sorting with proper types
  • Add pagination with typed handlers
  • Support custom cell renderers

Exercise 3: Form Builder

Build a type-safe form component:

  • Create FormField interface with different field types
  • Type form state and validation errors
  • Implement type-safe form submission
  • Add field-level and form-level validation
  • Support different input types with proper typing