You are viewing a preview of this lesson. Sign in to start learning
Back to React

TypeScript Integration

Add static typing for improved code quality and DX

TypeScript Integration with React

Master React development with TypeScript using free flashcards and spaced repetition practice. This lesson covers type definitions for components, props and state typing, hooks with TypeScript, and advanced patternsβ€”essential concepts for building type-safe React applications.

TypeScript has become the industry standard for React development, offering compile-time error detection, superior IDE support, and self-documenting code. By the end of this lesson, you'll understand how to leverage TypeScript's powerful type system to catch bugs before they reach production and build more maintainable React applications.

Welcome to TypeScript + React πŸ’»βš›οΈ

Combining TypeScript with React transforms your development experience from error-prone guesswork to confident, predictable coding. TypeScript adds static type checking to JavaScript, catching mistakes during development rather than at runtime. For React developers, this means:

  • Autocomplete superpowers: Your IDE knows exactly what props a component accepts
  • Refactoring confidence: Rename a prop and TypeScript finds every usage
  • Self-documenting code: Type definitions serve as inline documentation
  • Fewer runtime errors: Type mismatches are caught before deployment

πŸ’‘ Did you know? According to a 2020 study, TypeScript reduces bugs by approximately 15% compared to plain JavaScript, with the biggest gains in large codebases.

Core Concepts

Setting Up TypeScript with React

To start a new React project with TypeScript:

npx create-react-app my-app --template typescript

For existing projects, install TypeScript and type definitions:

npm install --save typescript @types/react @types/react-dom @types/node

Your project needs a tsconfig.json configuration file. React apps typically use:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

Key settings explained:

  • "strict": true - Enables all strict type-checking options
  • "jsx": "react-jsx" - Uses the new JSX transform (React 17+)
  • "noEmit": true - TypeScript only checks types, doesn't output files

Typing Function Components

The most common pattern is using the React.FC (Function Component) type or explicitly typing props:

Method 1: Using React.FC

import React from 'react';

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

const Button: React.FC<ButtonProps> = ({ label, onClick, disabled = false }) => {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
};

Method 2: Explicit props typing (preferred by many)

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

const Button = ({ label, onClick, disabled = false }: ButtonProps) => {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
};

πŸ’‘ Tip: Many developers prefer the second approach because React.FC has some quirks (it implicitly includes children and makes typing return values stricter).

Key TypeScript features for props:

  • ? makes a property optional: disabled?: boolean
  • | creates union types: size: 'small' | 'medium' | 'large'
  • Default values provide fallback: disabled = false

Typing Props and State

Props with complex types:

interface User {
  id: number;
  name: string;
  email: string;
}

interface UserCardProps {
  user: User;
  onEdit: (userId: number) => void;
  badges?: string[];
  children: React.ReactNode;
}

const UserCard = ({ user, onEdit, badges, children }: UserCardProps) => {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {badges && <div>{badges.join(', ')}</div>}
      <button onClick={() => onEdit(user.id)}>Edit</button>
      {children}
    </div>
  );
};

Important prop types:

TypeUsageExample
React.ReactNodeAny renderable contentchildren prop
React.ReactElementSpecific React elementSingle component child
JSX.ElementJSX expression resultComponent return type
React.CSSPropertiesInline style objectstyle prop
React.HTMLAttributes<T>HTML element propsdiv, button attributes

State typing with useState:

import { useState } from 'react';

// Simple state - type inference works
const [count, setCount] = useState(0); // inferred as number

// Complex state - explicit typing
interface FormState {
  username: string;
  email: string;
  age: number | null;
}

const [formData, setFormData] = useState<FormState>({
  username: '',
  email: '',
  age: null
});

// State that might be undefined initially
const [user, setUser] = useState<User | null>(null);

⚠️ Common mistake: Forgetting to handle null states!

// ❌ BAD - user might be null!
const displayName = user.name;

// βœ… GOOD - handle null case
const displayName = user?.name ?? 'Guest';

Typing Hooks

useRef with DOM elements:

import { useRef, useEffect } from 'react';

const InputComponent = () => {
  // Specify the element type
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // TypeScript knows this might be null
    inputRef.current?.focus();
  }, []);
  
  return <input ref={inputRef} />;
};

useRef for mutable values:

const timerRef = useRef<number>(0);

const startTimer = () => {
  timerRef.current = window.setTimeout(() => {
    console.log('Timer expired');
  }, 1000);
};

useReducer with TypeScript:

import { useReducer } from 'react';

// Define state and action types
interface CounterState {
  count: number;
  lastAction: string;
}

type CounterAction = 
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset'; payload: number };

const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1, lastAction: 'increment' };
    case 'decrement':
      return { count: state.count - 1, lastAction: 'decrement' };
    case 'reset':
      return { count: action.payload, lastAction: 'reset' };
    default:
      return state;
  }
};

const Counter = () => {
  const [state, dispatch] = useReducer(counterReducer, { count: 0, lastAction: 'none' });
  
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset</button>
    </div>
  );
};

πŸ’‘ Power pattern: Discriminated unions (like CounterAction above) give you exhaustive checking. TypeScript ensures you handle every action type in your switch statement!

useContext with TypeScript:

import { createContext, useContext, useState } from 'react';

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// Create context with undefined default (provide actual value in Provider)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// Custom hook with type safety
const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
};

const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

Event Handling

React events are SyntheticEvent types, not native browser events:

import React from 'react';

const FormComponent = () => {
  // Generic form submission
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log('Form submitted');
  };
  
  // Input change events
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    console.log(event.target.value);
  };
  
  // Button clicks
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    console.log('Button clicked at:', event.clientX, event.clientY);
  };
  
  // Keyboard events
  const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Enter') {
      console.log('Enter pressed');
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyPress={handleKeyPress} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
};

Common event types:

EventTypeTarget Element
ClickReact.MouseEvent<HTMLButtonElement>
ChangeReact.ChangeEvent<HTMLInputElement>
SubmitReact.FormEvent<HTMLFormElement>
KeyboardReact.KeyboardEvent<HTMLInputElement>
FocusReact.FocusEvent<HTMLInputElement>

Detailed Examples

Example 1: Typed Todo Application πŸ“

A complete todo app demonstrating props, state, and event typing:

import { useState } from 'react';

// Domain types
interface Todo {
  id: number;
  text: string;
  completed: boolean;
  createdAt: Date;
}

// Component props
interface TodoItemProps {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}

const TodoItem = ({ todo, onToggle, onDelete }: TodoItemProps) => {
  return (
    <div style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
};

const TodoApp = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [inputValue, setInputValue] = useState('');
  
  const addTodo = () => {
    if (inputValue.trim() === '') return;
    
    const newTodo: Todo = {
      id: Date.now(),
      text: inputValue,
      completed: false,
      createdAt: new Date()
    };
    
    setTodos(prev => [...prev, newTodo]);
    setInputValue('');
  };
  
  const toggleTodo = (id: number) => {
    setTodos(prev => prev.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const deleteTodo = (id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };
  
  return (
    <div>
      <h1>Todo List</h1>
      <input
        value={inputValue}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value)}
        onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {
          if (e.key === 'Enter') addTodo();
        }}
      />
      <button onClick={addTodo}>Add</button>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={toggleTodo}
          onDelete={deleteTodo}
        />
      ))}
    </div>
  );
};

Key TypeScript features used:

  • Interface composition for complex domain models
  • Function type signatures for callbacks: (id: number) => void
  • Array typing with generics: Todo[]
  • Event typing with specific elements

Example 2: Generic Component with Constraints 🎯

Generics make components reusable across different data types:

interface SelectOption {
  value: string | number;
  label: string;
}

interface SelectProps<T extends SelectOption> {
  options: T[];
  value: T['value'];
  onChange: (value: T['value']) => void;
  renderOption?: (option: T) => React.ReactNode;
}

function Select<T extends SelectOption>(
  { options, value, onChange, renderOption }: SelectProps<T>
) {
  return (
    <select
      value={value}
      onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
        const selectedValue = e.target.value;
        // Convert back to number if original was number
        const option = options.find(opt => String(opt.value) === selectedValue);
        if (option) onChange(option.value);
      }}
    >
      {options.map(option => (
        <option key={option.value} value={option.value}>
          {renderOption ? renderOption(option) : option.label}
        </option>
      ))}
    </select>
  );
}

// Usage with extended interface
interface ColorOption extends SelectOption {
  hex: string;
}

const ColorPicker = () => {
  const [color, setColor] = useState<string>('red');
  
  const colors: ColorOption[] = [
    { value: 'red', label: 'Red', hex: '#ff0000' },
    { value: 'blue', label: 'Blue', hex: '#0000ff' },
    { value: 'green', label: 'Green', hex: '#00ff00' }
  ];
  
  return (
    <Select
      options={colors}
      value={color}
      onChange={setColor}
      renderOption={(option) => (
        <span>
          <span style={{ backgroundColor: option.hex, width: 10, height: 10, display: 'inline-block' }} />
          {option.label}
        </span>
      )}
    />
  );
};

Generic patterns explained:

  • <T extends SelectOption> - T must have at least value and label
  • T['value'] - Index access type, gets the type of the value property
  • renderOption?: (option: T) => React.ReactNode - Optional custom renderer

πŸ’‘ Pro tip: Generics with constraints give you type safety while maintaining flexibility!

Example 3: Custom Hook with TypeScript 🎣

Custom hooks benefit enormously from TypeScript:

import { useState, useEffect } from 'react';

// Hook return type
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

// Generic fetch hook
function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [refetchIndex, setRefetchIndex] = useState(0);
  
  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);
      
      try {
        const response = await fetch(url, options);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const json = await response.json();
        setData(json as T);
      } catch (e) {
        setError(e instanceof Error ? e : new Error('Unknown error'));
      } finally {
        setLoading(false);
      }
    };
    
    fetchData();
  }, [url, refetchIndex]);
  
  const refetch = () => setRefetchIndex(prev => prev + 1);
  
  return { data, loading, error, refetch };
}

// Usage with typed API response
interface User {
  id: number;
  name: string;
  email: string;
}

const UserList = () => {
  const { data: users, loading, error, refetch } = useFetch<User[]>(
    'https://api.example.com/users'
  );
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!users) return <div>No data</div>;
  
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          {user.name} - {user.email}
        </div>
      ))}
      <button onClick={refetch}>Refresh</button>
    </div>
  );
};

TypeScript advantages in hooks:

  • Generic <T> lets you specify return data type
  • Return type interface documents what the hook provides
  • Null checks are enforced by the type system
  • IDE autocomplete knows exact structure of users

Example 4: Advanced Prop Patterns πŸš€

Discriminated unions for variant props:

// Different prop sets based on variant
type ButtonProps = 
  | {
      variant: 'primary';
      onClick: () => void;
      label: string;
    }
  | {
      variant: 'link';
      href: string;
      label: string;
      external?: boolean;
    }
  | {
      variant: 'icon';
      icon: React.ReactNode;
      onClick: () => void;
      ariaLabel: string;
    };

const Button = (props: ButtonProps) => {
  // TypeScript knows which props are available based on variant
  if (props.variant === 'primary') {
    return <button onClick={props.onClick}>{props.label}</button>;
  }
  
  if (props.variant === 'link') {
    return (
      <a 
        href={props.href} 
        target={props.external ? '_blank' : undefined}
        rel={props.external ? 'noopener noreferrer' : undefined}
      >
        {props.label}
      </a>
    );
  }
  
  // variant === 'icon'
  return (
    <button onClick={props.onClick} aria-label={props.ariaLabel}>
      {props.icon}
    </button>
  );
};

// Usage - TypeScript enforces correct props for each variant
<Button variant="primary" onClick={() => {}} label="Click me" />
<Button variant="link" href="/home" label="Home" />
<Button variant="icon" icon={<span>πŸ”</span>} onClick={() => {}} ariaLabel="Search" />

Extending HTML element props:

import React from 'react';

// Inherit all button props + add custom ones
interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant: 'primary' | 'secondary';
  loading?: boolean;
}

const CustomButton = ({ variant, loading, children, ...rest }: CustomButtonProps) => {
  return (
    <button
      {...rest}
      className={`btn btn-${variant} ${rest.className || ''}`}
      disabled={loading || rest.disabled}
    >
      {loading ? 'Loading...' : children}
    </button>
  );
};

// Now supports all standard button props + custom ones
<CustomButton 
  variant="primary" 
  loading={false}
  onClick={() => console.log('clicked')}
  type="submit"
  disabled={false}
>
  Submit
</CustomButton>

Common Mistakes ⚠️

1. Using any as an escape hatch

// ❌ BAD - defeats the purpose of TypeScript
const handleData = (data: any) => {
  console.log(data.name); // No type checking!
};

// βœ… GOOD - use unknown and type guards
const handleData = (data: unknown) => {
  if (typeof data === 'object' && data !== null && 'name' in data) {
    console.log((data as { name: string }).name);
  }
};

// βœ… BETTER - define proper types
interface DataType {
  name: string;
}

const handleData = (data: DataType) => {
  console.log(data.name);
};

2. Forgetting to handle null/undefined

// ❌ BAD - might crash if user is null
const UserProfile = ({ user }: { user: User | null }) => {
  return <div>{user.name}</div>; // Error: user might be null
};

// βœ… GOOD - handle null case
const UserProfile = ({ user }: { user: User | null }) => {
  if (!user) return <div>No user</div>;
  return <div>{user.name}</div>;
};

// βœ… ALSO GOOD - optional chaining
const UserProfile = ({ user }: { user: User | null }) => {
  return <div>{user?.name ?? 'Guest'}</div>;
};

3. Incorrect event types

// ❌ BAD - using generic Event type
const handleClick = (e: Event) => {
  console.log(e.target.value); // Error: Event doesn't have target.value
};

// βœ… GOOD - use specific React event type
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.currentTarget); // Typed as HTMLButtonElement
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value); // Works!
};

4. Over-typing simple components

// ❌ BAD - unnecessary complexity for simple cases
interface GreetingProps {
  name: string;
}

const Greeting: React.FC<GreetingProps> = ({ name }): JSX.Element => {
  return <div>Hello {name}</div>;
};

// βœ… GOOD - let TypeScript infer when possible
const Greeting = ({ name }: { name: string }) => {
  return <div>Hello {name}</div>;
};

5. Not using discriminated unions

// ❌ BAD - optional props create impossible states
interface ApiState {
  loading: boolean;
  data?: User[];
  error?: string;
}
// Problem: Can have loading=false, data=undefined, error=undefined

// βœ… GOOD - discriminated union ensures valid states only
type ApiState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

// Now impossible to have invalid state combinations

6. Mutating props or state directly

// ❌ BAD - mutating state directly
const addItem = () => {
  items.push(newItem); // TypeScript won't stop this, but React will break
  setItems(items);
};

// βœ… GOOD - create new array
const addItem = () => {
  setItems([...items, newItem]);
};

// βœ… ALSO GOOD - use callback form
const addItem = () => {
  setItems(prev => [...prev, newItem]);
};

Key Takeaways 🎯

βœ… Start strict: Enable "strict": true in tsconfig.json from day one

βœ… Interface for objects, type for unions: Use interface for object shapes, type for unions and complex types

βœ… Let inference work: Don't over-annotate; TypeScript can infer many types automatically

βœ… Handle null/undefined: Always account for nullable types with guards or optional chaining

βœ… Use discriminated unions: Model state machines and variants with tagged unions

βœ… Generic components: Use generics to make reusable components type-safe

βœ… Proper event types: Use React's SyntheticEvent types, not native browser events

βœ… Custom hooks return objects: Return objects with named properties, not arrays (unlike useState)

βœ… Extend HTML props: Use extends React.HTMLAttributes<T> to inherit native props

βœ… Gradual adoption: You can add TypeScript incrementally with .tsx files alongside .jsx

πŸ“‹ Quick Reference Card

PatternSyntax
Function Component({ prop }: Props) => JSX
Optional Propname?: string
Union Typesize: 'sm' | 'md' | 'lg'
State with TypeuseState<User | null>(null)
Ref for DOMuseRef<HTMLInputElement>(null)
Event HandlerReact.ChangeEvent<HTMLInputElement>
Children Propchildren: React.ReactNode
Generic Componentfunction Comp<T>(props: Props<T>)
Extend HTML Propsextends React.ButtonHTMLAttributes
Custom Hook Return{ data: T | null, loading: boolean }

Memory aid - The TypeScript Trio:

  • πŸ” Interface = Object shape definition
  • 🎭 Type = Unions, aliases, complex types
  • ⚑ Generic = Flexible, reusable type variables

πŸ“š Further Study

  1. React TypeScript Cheatsheet: https://react-typescript-cheatsheet.netlify.app/ - Comprehensive patterns and examples
  2. TypeScript Handbook - React: https://www.typescriptlang.org/docs/handbook/react.html - Official TypeScript documentation for React
  3. Total TypeScript - React Course: https://www.totaltypescript.com/tutorials/react-with-typescript - Free interactive tutorials

🧠 Mnemonic for event types: "Change Forms, Click Buttons, Submit Forms, Type Keys" = ChangeEvent, MouseEvent, FormEvent, KeyboardEvent

πŸ’‘ Practice project idea: Convert an existing JavaScript React project to TypeScript file by file, starting with components that have the fewest dependencies.