TypeScript

TypeScript with React

30 min Lesson 19 of 40

TypeScript with React

TypeScript and React are a powerful combination that brings type safety to your React applications. In this lesson, we'll explore how to properly type React components, props, state, hooks, events, context, and refs for a fully type-safe React development experience.

Setting Up React with TypeScript

The easiest way to start a React + TypeScript project is using Create React App with the TypeScript template.

Project Setup:
<# Create new React app with TypeScript
npx create-react-app my-app --template typescript

# Or with Vite (faster alternative)
npm create vite@latest my-app -- --template react-ts

# Project structure
my-app/
├── src/
│   ├── App.tsx          # TypeScript + JSX
│   ├── index.tsx
│   └── components/
├── tsconfig.json         # TypeScript configuration
└── package.json
>

Typing Function Components

Function components in TypeScript can be typed using the React.FC type or by explicitly typing the props parameter.

Function Component Types:
<import React from 'react';

// Method 1: Using React.FC (Functional Component)
interface UserCardProps {
  name: string;
  email: string;
  age?: number;
}

const UserCard: React.FC<UserCardProps> = ({ name, email, age }) => {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
      {age && <p>Age: {age}</p>}
    </div>
  );
};

// Method 2: Explicit props typing (preferred)
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

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

// With children prop
interface ContainerProps {
  title: string;
  children: React.ReactNode;
}

const Container = ({ title, children }: ContainerProps) => {
  return (
    <div>
      <h1>{title}</h1>
      {children}
    </div>
  );
};
>
Tip: The explicit props typing method (Method 2) is generally preferred as it's more straightforward and doesn't include the implicit children prop that React.FC adds.

Typing Props with Children

When your component accepts children, use React.ReactNode for maximum flexibility.

Children Props:
<// Single child of specific type
interface IconProps {
  children: React.ReactElement;
  size?: number;
}

// Multiple children
interface CardProps {
  children: React.ReactNode; // Can be anything React can render
  header?: React.ReactNode;
  footer?: React.ReactNode;
}

// Render prop pattern
interface DataListProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
}

function DataList<T>({ data, renderItem }: DataListProps<T>) {
  return (
    <ul>
      {data.map((item, index) => (
        <li key={index}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// Usage
<DataList
  data={users}
  renderItem={(user, index) => (
    <div>{user.name}</div>
  )}
/>
>

Typing State with useState

TypeScript can often infer the state type from the initial value, but explicit typing is useful for complex types or when the initial value is null/undefined.

useState Typing:
<import { useState } from 'react';

// Type inference (recommended when possible)
const [count, setCount] = useState(0); // Type: number
const [name, setName] = useState('John'); // Type: string

// Explicit typing
const [age, setAge] = useState<number>(25);

// Union types
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');

// Complex objects
interface User {
  id: number;
  name: string;
  email: string;
}

const [user, setUser] = useState<User | null>(null);

// Arrays
const [users, setUsers] = useState<User[]>([]);

// Updating state with type safety
setUser({
  id: 1,
  name: 'John',
  email: 'john@example.com'
});

// Functional updates are type-safe
setCount(prevCount => prevCount + 1);
setUsers(prevUsers => [...prevUsers, newUser]);

// Error: Type string is not assignable to type User
// setUser('invalid');
>

Typing Event Handlers

React event handlers need to be typed with the appropriate event type for type-safe event handling.

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

interface FormComponentProps {
  onSubmit: (data: { email: string; password: string }) => void;
}

const FormComponent = ({ onSubmit }: FormComponentProps) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  // Input change event
  const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  // Form submit event
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

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

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

  // Inline event handler (type inference works)
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={handleEmailChange}
        onKeyDown={handleKeyDown}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit" onClick={handleClick}>
        Submit
      </button>
    </form>
  );
};

// Common event types:
// - MouseEvent<HTMLButtonElement>
// - ChangeEvent<HTMLInputElement>
// - ChangeEvent<HTMLTextAreaElement>
// - ChangeEvent<HTMLSelectElement>
// - FormEvent<HTMLFormElement>
// - KeyboardEvent<HTMLInputElement>
// - FocusEvent<HTMLInputElement>
>
Note: Always specify the HTML element type in the generic (e.g., HTMLInputElement) for accurate type checking of the event target.

Typing useEffect and useRef

The useEffect hook doesn't require explicit typing, but useRef needs type arguments for proper type safety.

useEffect and useRef:
<import { useEffect, useRef } from 'react';

const Component = () => {
  // useEffect - no typing needed, but cleanup must match return type
  useEffect(() => {
    const timer = setTimeout(() => {
      console.log('Delayed action');
    }, 1000);

    // Cleanup function
    return () => clearTimeout(timer);
  }, []);

  // Ref for DOM elements
  const inputRef = useRef<HTMLInputElement>(null);
  const divRef = useRef<HTMLDivElement>(null);

  // Ref for mutable values
  const countRef = useRef<number>(0);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    // Type-safe ref access
    if (inputRef.current) {
      inputRef.current.focus();
    }

    if (divRef.current) {
      console.log(divRef.current.offsetHeight);
    }
  }, []);

  const startTimer = () => {
    timerRef.current = setTimeout(() => {
      countRef.current += 1;
    }, 1000);
  };

  const stopTimer = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  };

  return (
    <div ref={divRef}>
      <input ref={inputRef} />
      <button onClick={startTimer}>Start</button>
      <button onClick={stopTimer}>Stop</button>
    </div>
  );
};
>

Typing Custom Hooks

Custom hooks follow the same typing patterns as regular functions, with explicit return types for clarity.

Custom Hook Types:
<import { useState, useEffect } from 'react';

// Simple custom hook
function useToggle(initialValue: boolean = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue(prev => !prev);
  return [value, toggle];
}

// Custom hook with options
interface UseFetchOptions {
  method?: 'GET' | 'POST';
  headers?: Record<string, string>;
}

interface UseFetchReturn<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string, options?: UseFetchOptions): UseFetchReturn<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = async () => {
    try {
      setLoading(true);
      const response = await fetch(url, options);
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, [url]);

  return { data, loading, error, refetch: fetchData };
}

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

const UserComponent = () => {
  const { data, loading, error } = useFetch<User>('/api/user/123');

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;

  return <div>{data.name}</div>;
};
>
Best Practice: Always define explicit return types for custom hooks to improve code clarity and catch type errors early.

Typing Context API

The Context API requires careful typing to ensure type safety across provider and consumer components.

Context Typing:
<import { createContext, useContext, useState, ReactNode } from 'react';

// Define context type
interface User {
  id: string;
  name: string;
  email: string;
}

interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}

// Create context with undefined default (ensures provider is used)
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Provider component
interface AuthProviderProps {
  children: ReactNode;
}

export const AuthProvider = ({ children }: AuthProviderProps) => {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    // API call logic
    const userData = await fetchUser(email, password);
    setUser(userData);
  };

  const logout = () => {
    setUser(null);
  };

  const value: AuthContextType = {
    user,
    login,
    logout,
    isAuthenticated: !!user
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

// Custom hook for using context
export const useAuth = (): AuthContextType => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

// Usage in components
const ProfileComponent = () => {
  const { user, logout, isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <div>Please log in</div>;
  }

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
};
>

Typing Component Props with Generics

Generic components allow for flexible, reusable components with type safety.

Generic Components:
<interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string | number;
}

function Select<T>({
  options,
  value,
  onChange,
  getLabel,
  getValue
}: SelectProps<T>) {
  return (
    <select
      value={getValue(value)}
      onChange={(e) => {
        const selectedOption = options.find(
          (opt) => getValue(opt).toString() === e.target.value
        );
        if (selectedOption) {
          onChange(selectedOption);
        }
      }}
    >
      {options.map((option) => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}

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

const UserSelect = () => {
  const [selectedUser, setSelectedUser] = useState<User>(users[0]);

  return (
    <Select
      options={users}
      value={selectedUser}
      onChange={setSelectedUser}
      getLabel={(user) => user.name}
      getValue={(user) => user.id}
    />
  );
};

// Works with primitives too
const StringSelect = () => {
  const [selected, setSelected] = useState('option1');

  return (
    <Select
      options={['option1', 'option2', 'option3']}
      value={selected}
      onChange={setSelected}
      getLabel={(opt) => opt}
      getValue={(opt) => opt}
    />
  );
};
>

Typing Higher-Order Components (HOCs)

Higher-Order Components require careful typing to preserve the wrapped component's props.

HOC Typing:
<import { ComponentType } from 'react';

// Injected props
interface WithLoadingProps {
  loading: boolean;
}

// HOC function
function withLoading<P extends object>(
  Component: ComponentType<P>
): ComponentType<P & WithLoadingProps> {
  return (props: P & WithLoadingProps) => {
    const { loading, ...rest } = props;

    if (loading) {
      return <div>Loading...</div>;
    }

    return <Component {...(rest as P)} />;
  };
}

// Usage
interface UserListProps {
  users: User[];
  onUserClick: (user: User) => void;
}

const UserList = ({ users, onUserClick }: UserListProps) => {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={() => onUserClick(user)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
};

const UserListWithLoading = withLoading(UserList);

// Component now requires both original props and loading prop
<UserListWithLoading
  users={users}
  onUserClick={handleClick}
  loading={isLoading}
/>
>
Exercise: Create a fully typed React component that:
  1. Accepts a list of items via props
  2. Manages selected item state with useState
  3. Uses useEffect to fetch data when component mounts
  4. Handles click events with proper typing
  5. Provides a custom hook to access the selected item
Summary: TypeScript dramatically improves React development by catching errors early, providing excellent IntelliSense, and making refactoring safer. Master these typing patterns for professional React + TypeScript development.