TypeScript has become an essential tool for React developers, enabling type safety, better tooling, and improved developer experience. In this article, we'll explore best practices for using TypeScript with React to create maintainable, scalable, and robust applications.

Why TypeScript with React?

Before diving into best practices, let's understand why TypeScript has become so popular in the React ecosystem:

  • Type Safety: Catch errors during development rather than at runtime
  • Better Developer Experience: Improved autocompletion, navigation, and refactoring
  • Self-Documenting Code: Types serve as documentation that can't become outdated
  • Enhanced Team Collaboration: Clear interfaces for component props and state

While there's a learning curve, the benefits of TypeScript far outweigh the initial investment, especially for larger applications and teams.

Setting Up a TypeScript React Project

You can create a new TypeScript React project using Create React App:

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

Or with Next.js:

npx create-next-app@latest --ts

For existing projects, you can add TypeScript incrementally:

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

Component Type Definitions

Let's explore the best practices for typing React components.

Functional Components

For functional components, use React.FC or the more explicit React.FunctionComponent:

import React from 'react';

// Props interface
interface GreetingProps {
  name: string;
  age?: number; // Optional prop
}

// Using React.FC
const Greeting: React.FC = ({ name, age }) => {
  return (
    

Hello, {name}!

{age &&

You are {age} years old.

}
); }; export default Greeting;

However, there's a growing trend to avoid React.FC in favor of explicit return types:

// Explicit return type approach
const Greeting = ({ name, age }: GreetingProps): JSX.Element => {
  return (
    

Hello, {name}!

{age &&

You are {age} years old.

}
); };

Pro Tip

While React.FC provides implicit children prop typing, the explicit approach gives you more control and avoids potential issues with children handling. Choose the approach that works best for your team's style.

Class Components

For class components, use React.Component with generics for props and state:

import React, { Component } from 'react';

interface CounterProps {
  initialCount: number;
}

interface CounterState {
  count: number;
}

class Counter extends Component {
  constructor(props: CounterProps) {
    super(props);
    this.state = {
      count: props.initialCount
    };
  }

  increment = (): void => {
    this.setState(prevState => ({
      count: prevState.count + 1
    }));
  };

  render(): JSX.Element {
    return (
      

Count: {this.state.count}

); } }

Advanced Prop Typing

Beyond basic type definitions, TypeScript offers powerful ways to type component props.

Union Types for Props

Use union types when a prop can accept multiple types:

interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  onClick: () => void;
  disabled?: boolean;
  children: React.ReactNode;
}

const Button: React.FC = ({
  variant,
  size = 'medium',
  onClick,
  disabled = false,
  children
}) => {
  return (
    
  );
};

Extending HTML Element Props

When creating wrapper components for HTML elements, extend the built-in HTML props:

import React, { ButtonHTMLAttributes } from 'react';

// Extending HTML button attributes
interface CustomButtonProps extends ButtonHTMLAttributes {
  variant: 'primary' | 'secondary' | 'danger';
}

const CustomButton: React.FC = ({
  variant,
  children,
  className,
  ...restProps
}) => {
  return (
    
  );
};

Typing Hooks

React hooks require proper typing to maintain type safety throughout your application.

useState Hook

TypeScript can infer the type from the initial value, but for more complex types, provide an explicit type:

// Simple types can use inference
const [count, setCount] = useState(0);
const [name, setName] = useState('');

// Complex types need explicit typing
interface User {
  id: number;
  name: string;
  email: string;
}

// Type the state directly
const [user, setUser] = useState(null);

// Or provide initial data
const [users, setUsers] = useState([]);

useRef Hook

Type useRef properly to ensure type safety when accessing current:

// For DOM elements
const inputRef = useRef(null);

// For instance variables
const intervalRef = useRef(null);

// Later usage
useEffect(() => {
  if (inputRef.current) {
    inputRef.current.focus();
  }
  
  intervalRef.current = window.setInterval(() => {
    console.log('Interval running');
  }, 1000);
  
  return () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
    }
  };
}, []);

useReducer Hook

Type both the state and actions for useReducer:

// State type
interface CounterState {
  count: number;
}

// Action types
type CounterAction = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET' }
  | { type: 'SET_COUNT'; payload: number };

// Reducer function
const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    case 'SET_COUNT':
      return { count: action.payload };
    default:
      return state;
  }
};

// In component
const [state, dispatch] = useReducer(counterReducer, { count: 0 });

Typing API Responses

Creating interfaces for API responses ensures consistent data handling:

// API response interfaces
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

interface ApiResponse {
  data: T;
  status: number;
  message: string;
}

// Fetching data with types
const fetchUsers = async (): Promise> => {
  const response = await fetch('/api/users');
  const data: ApiResponse = await response.json();
  return data;
};

// In component
const UserList: React.FC = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const getUsers = async () => {
      try {
        const result = await fetchUsers();
        setUsers(result.data);
      } catch (err) {
        setError('Failed to fetch users');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    getUsers();
  }, []);

  if (loading) return 
Loading...
; if (error) return
Error: {error}
; return (
    {users.map(user => (
  • {user.name} - {user.role}
  • ))}
); };

Advanced TypeScript Patterns in React

Let's explore some advanced TypeScript patterns that can enhance your React applications.

Discriminated Unions for Component Props

When a component can render in multiple ways based on props:

// Base props for all variants
interface BaseAlertProps {
  title: string;
}

// Success alert
interface SuccessAlertProps extends BaseAlertProps {
  variant: 'success';
  successMessage: string;
}

// Error alert
interface ErrorAlertProps extends BaseAlertProps {
  variant: 'error';
  errorMessage: string;
  errorCode?: number;
}

// Info alert
interface InfoAlertProps extends BaseAlertProps {
  variant: 'info';
  infoDetails: string;
}

// Union type of all possible props
type AlertProps = SuccessAlertProps | ErrorAlertProps | InfoAlertProps;

const Alert: React.FC = (props) => {
  const { title, variant } = props;
  
  // Common rendering logic
  return (
    

{title}

{/* Variant-specific rendering */} {variant === 'success' &&

{props.successMessage}

} {variant === 'error' && ( <>

{props.errorMessage}

{props.errorCode &&

Error code: {props.errorCode}

} )} {variant === 'info' &&

{props.infoDetails}

}
); };

Generic Components

Create reusable components that can work with different data types:

interface ListProps {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

function List({ items, renderItem }: ListProps) {
  return (
    
    {items.map((item, index) => (
  • {renderItem(item)}
  • ))}
); } // Usage interface User { id: number; name: string; } const users: User[] = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ]; // Type is inferred from users array {user.name}} />

Useful TypeScript Utilities for React

TypeScript provides utility types that are particularly helpful in React projects:

// Partial makes all properties optional
type PartialUser = Partial;
// { id?: number; name?: string; email?: string; }

// Required makes all properties required
type RequiredUser = Required;
// { id: number; name: string; email: string; }

// Pick selects specific properties
type UserCredentials = Pick;
// { email: string; id: number; }

// Omit removes specific properties
type UserWithoutEmail = Omit;
// { id: number; name: string; }

// ReturnType gets the return type of a function
type FetchUsersReturn = ReturnType;
// Promise>

Conclusion

TypeScript enhances the React development experience by providing static type checking, improved tooling, and better documentation. By following these best practices, you can:

  • Reduce runtime errors through compile-time type checking
  • Improve code maintainability and scalability
  • Enhance team collaboration with self-documenting interfaces
  • Create more robust React applications

While there's a learning curve to TypeScript, the investment pays dividends as your application grows in complexity. Start with simple type annotations and gradually adopt more advanced patterns as your team becomes comfortable with TypeScript.