DEV Community

Cover image for React TypeScript Best Practices: Complete Guide for Type-Safe React Applications
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

React TypeScript Best Practices: Complete Guide for Type-Safe React Applications

When I first started using TypeScript with React, I thought it would just add extra typing overhead. Boy, was I wrong. TypeScript has saved me countless hours by catching bugs before they reach production, and it's made my codebase infinitely more maintainable. But using TypeScript effectively in React requires understanding some patterns and best practices that aren't always obvious.

TypeScript brings compile-time type checking to React, which means you catch errors while writing code, not when users report bugs. It also provides incredible IDE support—autocomplete, refactoring, and navigation all work better when TypeScript understands your code structure. But to get these benefits, you need to type your components, props, hooks, and event handlers correctly.

📖 Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.

Why TypeScript with React?

TypeScript with React provides:

  • Type safety - Catch errors at compile time
  • Better IDE support - Autocomplete, IntelliSense, and refactoring
  • Self-documenting code - Types serve as documentation
  • Easier refactoring - TypeScript catches breaking changes
  • Better collaboration - Types help team members understand code
  • Runtime error prevention - Catch bugs before they reach production

Type Definitions for Props

Define component prop types using interfaces or types:

// Define types
type Product = {
  id: string | number;
  name: string;
  price: number;
  stock: number;
  categoryId: number;
  categoryName?: string;
};

// Component with typed props
interface ProductCardProps {
  product: Product;
  onEdit?: (id: string | number) => void;
  onDelete?: (id: string | number) => void;
  showActions?: boolean;
}

function ProductCard({ 
  product, 
  onEdit, 
  onDelete, 
  showActions = true 
}: ProductCardProps) {
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <p>Stock: {product.stock}</p>
      {showActions && (
        <div>
          {onEdit && <button onClick={() => onEdit(product.id)}>Edit</button>}
          {onDelete && <button onClick={() => onDelete(product.id)}>Delete</button>}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Type vs Interface

  • Interfaces - Better for object shapes, extendable, can be merged
  • Types - Better for unions, intersections, and computed types
// Interface - good for component props
interface ButtonProps {
  label: string;
  onClick: () => void;
}

// Type - good for unions
type Status = "loading" | "success" | "error";

// Type - good for intersections
type ButtonWithIcon = ButtonProps & {
  icon: React.ReactNode;
};
Enter fullscreen mode Exit fullscreen mode

Typing React Hooks

useState Hook

Type useState explicitly or let TypeScript infer:

// Explicit typing
const [name, setName] = useState<string>("");
const [count, setCount] = useState<number>(0);
const [products, setProducts] = useState<Product[]>([]);

// Type inference (TypeScript infers from initial value)
const [isLoading, setIsLoading] = useState(false); // boolean
const [user, setUser] = useState(null); // null - need to type this
const [user, setUser] = useState<User | null>(null); // Better
Enter fullscreen mode Exit fullscreen mode

useEffect Hook

useEffect is automatically typed, but you can type dependencies:

useEffect(() => {
  // Effect logic
}, [dependency1, dependency2]); // TypeScript checks dependencies
Enter fullscreen mode Exit fullscreen mode

Custom Hooks

Create typed custom hooks with explicit return types:

import { useState, useEffect } from "react";
import { useGetProductsQuery } from "../../state/products/productSlice";

type UseProductsReturn = {
  products: Product[];
  isLoading: boolean;
  isError: boolean;
  error: any;
  refetch: () => void;
};

function useProducts(): UseProductsReturn {
  const { data, isLoading, isError, error, refetch } = useGetProductsQuery({});

  return {
    products: data?.data || [],
    isLoading,
    isError,
    error,
    refetch,
  };
}

// Usage
function ProductsPage() {
  const { products, isLoading } = useProducts();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Forward Refs

Type forwardRef components properly:

import React, { forwardRef } from "react";

type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label: string;
  error?: string;
  required?: boolean;
};

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, required = false, ...rest }, ref) => {
    return (
      <div>
        <label>
          {label}
          {required && <span className="text-red-500">*</span>}
        </label>
        <input
          ref={ref}
          {...rest}
          className={`input ${error ? "error" : ""}`}
        />
        {error && <p className="text-red-500 text-xs">{error}</p>}
      </div>
    );
  }
);

Input.displayName = "Input";
export default Input;
Enter fullscreen mode Exit fullscreen mode

Event Handlers

Type event handlers using React's built-in event types:

function ProductForm() {
  const [name, setName] = useState<string>("");
  const [price, setPrice] = useState<number>(0);

  // Form submit event
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // Handle submit
  };

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

  // Number input change
  const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPrice(Number(e.target.value));
  };

  // Button click event
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    // Handle click
  };

  // Select change event
  const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const value = e.target.value;
    // Handle select change
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={handleNameChange}
      />
      <input
        type="number"
        value={price}
        onChange={handlePriceChange}
      />
      <button type="submit" onClick={handleClick}>
        Submit
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common Event Types

  • React.FormEvent<HTMLFormElement> - Form submission
  • React.ChangeEvent<HTMLInputElement> - Input changes
  • React.ChangeEvent<HTMLSelectElement> - Select changes
  • React.ChangeEvent<HTMLTextAreaElement> - Textarea changes
  • React.MouseEvent<HTMLButtonElement> - Button clicks
  • React.KeyboardEvent<HTMLInputElement> - Keyboard events

Generic Components

Create reusable generic components with TypeScript generics:

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

function Select<T extends string | number>({
  options,
  value,
  onChange,
  placeholder,
}: SelectProps<T>) {
  return (
    <select
      value={value}
      onChange={(e) => onChange(e.target.value as T)}
    >
      {placeholder && <option value="">{placeholder}</option>}
      {options.map((option) => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

// Usage with string
<Select<string>
  options={[{ value: "pcs", label: "Pieces" }, { value: "kg", label: "Kilograms" }]}
  value={unit}
  onChange={setUnit}
/>

// Usage with number
<Select<number>
  options={[{ value: 1, label: "One" }, { value: 2, label: "Two" }]}
  value={selectedNumber}
  onChange={setSelectedNumber}
/>
Enter fullscreen mode Exit fullscreen mode

TypeScript Utility Types

Leverage TypeScript utility types for type transformations:

// Extract types from API responses
type ProductResponse = {
  success: boolean;
  data: Product[];
};

type Product = ProductResponse["data"][number];

// Partial - makes all properties optional
type PartialProduct = Partial<Product>;
// { id?: string; name?: string; price?: number; ... }

// Required - makes all properties required
type RequiredProduct = Required<Product>;
// { id: string; name: string; price: number; ... }

// Pick - select specific properties
type ProductPreview = Pick<Product, "id" | "name" | "price">;
// { id: string; name: string; price: number; }

// Omit - exclude specific properties
type ProductWithoutId = Omit<Product, "id">;
// { name: string; price: number; stock: number; ... }

// Record - create object types
type ProductStatus = Record<string, "active" | "inactive" | "pending">;
// { [key: string]: "active" | "inactive" | "pending" }

// Extract - extract types from unions
type Status = "loading" | "success" | "error";
type SuccessStatus = Extract<Status, "success">; // "success"

// Exclude - exclude types from unions
type NonErrorStatus = Exclude<Status, "error">; // "loading" | "success"
Enter fullscreen mode Exit fullscreen mode

Using Utility Types in Components

// Create a form component that accepts partial product data
interface ProductFormProps {
  product?: Partial<Product>;
  onSubmit: (data: Omit<Product, "id">) => void;
}

function ProductForm({ product, onSubmit }: ProductFormProps) {
  // Form implementation
}

// Create a preview component that only needs specific fields
interface ProductPreviewProps {
  product: Pick<Product, "id" | "name" | "price">;
}

function ProductPreview({ product }: ProductPreviewProps) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Typing Children

Type React children properly:

// Using React.ReactNode (most common)
interface CardProps {
  title: string;
  children: React.ReactNode;
}

function Card({ title, children }: CardProps) {
  return (
    <div>
      <h2>{title}</h2>
      {children}
    </div>
  );
}

// Using React.ReactElement for single element
interface ButtonGroupProps {
  children: React.ReactElement<ButtonProps>[];
}

// Using specific element type
interface LayoutProps {
  children: React.ReactElement;
}
Enter fullscreen mode Exit fullscreen mode

Typing Context

Type React Context properly:

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

const AuthContext = React.createContext<AuthContextType | undefined>(undefined);

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const value: AuthContextType = {
    user,
    isAuthenticated: !!user,
    login: async (email, password) => {
      // Login logic
    },
    logout: () => {
      setUser(null);
    },
  };

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

// Custom hook with type safety
function useAuth(): AuthContextType {
  const context = React.useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Always type component props explicitly - Don't rely on inference for props
  2. Use interfaces for object shapes - More traditional for React props
  3. Use types for unions and intersections - Better for complex types
  4. Leverage type inference where possible - Let TypeScript infer simple types
  5. Use type utilities effectively - Pick, Omit, Partial for type transformations
  6. Avoid using 'any' - Use 'unknown' instead when type is truly unknown
  7. Create shared type definitions - Reuse types across components
  8. Use const assertions - For literal types and readonly data
  9. Type event handlers explicitly - Use React's built-in event types
  10. Use generics for reusable components - Make components type-safe and reusable

Avoiding Common Mistakes

// ❌ Bad - using 'any'
function Component(props: any) {
  return <div>{props.name}</div>;
}

// ✅ Good - explicit typing
interface ComponentProps {
  name: string;
}
function Component(props: ComponentProps) {
  return <div>{props.name}</div>;
}

// ❌ Bad - not typing useState properly
const [user, setUser] = useState(null);

// ✅ Good - explicit typing
const [user, setUser] = useState<User | null>(null);

// ❌ Bad - using 'any' for events
const handleChange = (e: any) => {
  setName(e.target.value);
};

// ✅ Good - using proper event types
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setName(e.target.value);
};
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

Conclusion

TypeScript enhances React development by providing type safety, better IDE support, and catching errors early. Following these best practices ensures maintainable, scalable React applications with excellent developer experience. The patterns shown here are used throughout modern React applications and inventory management systems.

Key Takeaways:

  • TypeScript provides compile-time type checking for React
  • Type component props explicitly using interfaces or types
  • Type hooks properly, especially custom hooks
  • Use React's event types for event handlers
  • Leverage generics for reusable components
  • Use utility types for type transformations
  • Avoid 'any' - use 'unknown' when needed
  • Create shared types for consistency across components

Whether you're building a simple component or a complex application, TypeScript provides the type safety you need. It catches errors before they reach production and makes your codebase more maintainable.


What's your experience with TypeScript and React? Share your tips and tricks in the comments below! 🚀


💡 Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.

If you found this guide helpful, consider checking out my other articles on React development and TypeScript best practices.

Top comments (0)