TypeScript Best Practices for React Developers

6 min readCong Dinh
TypeScript Best Practices for React Developers

TypeScript Best Practices for React Developers

TypeScript has become the standard in React development. In this article, we'll explore best practices that help you write better TypeScript code that's more maintainable and has fewer bugs.

Why TypeScript Matters

TypeScript brings many benefits:

  • Type safety: Catch errors at compile time instead of runtime
  • Better IDE support: Excellent autocomplete and IntelliSense
  • Refactoring confidence: Easy to refactor code without breaking things
  • Self-documenting code: Types serve as documentation

Best Practice 1: Use Interfaces for Props

❌ Don't

type ButtonProps = {
  text: string;
  onClick: any; // Avoid using any!
};

✅ Do

interface ButtonProps {
  text: string;
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  variant?: "primary" | "secondary" | "outline";
  disabled?: boolean;
  children?: React.ReactNode;
}
 
export function Button({ text, onClick, variant = "primary", disabled = false }: ButtonProps) {
  return (
    <button 
      className={`btn btn-${variant}`}
      onClick={onClick}
      disabled={disabled}
    >
      {text}
    </button>
  );
}

Why?

  • Interfaces can be extended and merged easily
  • Clear naming convention: ComponentNameProps
  • Optional properties with ?
  • Default values in destructuring

Best Practice 2: Generic Components

Generic components help create reusable components with type safety.

Example: 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" }: 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
interface User {
  id: number;
  name: string;
  email: string;
}
 
function UserList({ users }: { users: User[] }) {
  return (
    <List<User>
      items={users}
      renderItem={(user) => (
        <div>
          <h3>{user.name}</h3>
          <p>{user.email}</p>
        </div>
      )}
      keyExtractor={(user) => user.id}
      emptyMessage="No users yet"
    />
  );
}

Best Practice 3: Union Types for Props Variants

Use union types to enforce correct prop combinations.

Example: Button with Multiple Variants

type BaseButtonProps = {
  children: React.ReactNode;
  disabled?: boolean;
  className?: string;
};
 
type PrimaryButtonProps = BaseButtonProps & {
  variant: "primary";
  onClick: () => void;
};
 
type LinkButtonProps = BaseButtonProps & {
  variant: "link";
  href: string;
  target?: "_blank" | "_self";
};
 
type SubmitButtonProps = BaseButtonProps & {
  variant: "submit";
  form: string;
};
 
type ButtonProps = PrimaryButtonProps | LinkButtonProps | SubmitButtonProps;
 
function Button(props: ButtonProps) {
  const { variant, children, disabled, className } = props;
 
  if (variant === "link") {
    return (
      <a 
        href={props.href} 
        target={props.target}
        className={className}
      >
        {children}
      </a>
    );
  }
 
  if (variant === "submit") {
    return (
      <button 
        type="submit"
        form={props.form}
        disabled={disabled}
        className={className}
      >
        {children}
      </button>
    );
  }
 
  return (
    <button 
      onClick={props.onClick}
      disabled={disabled}
      className={className}
    >
      {children}
    </button>
  );
}

TypeScript will enforce the correct props for each variant!

Best Practice 4: Utility Types

Leverage TypeScript's built-in utility types.

Partial<T>

interface User {
  id: number;
  name: string;
  email: string;
  bio: string;
}
 
// Update user - don't need all fields
function updateUser(id: number, updates: Partial<User>) {
  // API call to update user
}
 
updateUser(1, { name: "New Name" }); // ✅ OK
updateUser(1, { email: "new@email.com", bio: "New bio" }); // ✅ OK

Pick<T, K> and Omit<T, K>

// Pick only some fields
type UserPreview = Pick<User, "id" | "name">;
 
// Omit some fields
type UserWithoutId = Omit<User, "id">;
 
// Usage in components
interface UserCardProps {
  user: UserPreview; // Only need id and name
}

Record<K, T>

// Map from string key to value type
type UserRole = "admin" | "editor" | "viewer";
 
const permissions: Record<UserRole, string[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

Best Practice 5: Custom Hooks with TypeScript

Type your custom hooks properly.

import { useState, useEffect } from 'react';
 
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}
 
function useFetch<T>(url: string): UseFetchResult<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);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const result = await response.json();
      setData(result);
      setError(null);
    } catch (e) {
      setError(e instanceof Error ? e : new Error('Unknown error'));
      setData(null);
    } finally {
      setLoading(false);
    }
  };
 
  useEffect(() => {
    fetchData();
  }, [url]);
 
  return { data, loading, error, refetch: fetchData };
}
 
// Usage
interface User {
  id: number;
  name: string;
}
 
function UserProfile({ userId }: { userId: number }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
 
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return null;
 
  return <div>{user.name}</div>;
}

Best Practice 6: Event Handlers

Type event handlers correctly for better type safety.

interface FormProps {
  onSubmit: (data: FormData) => void;
}
 
function MyForm({ onSubmit }: FormProps) {
  // Form submit handler
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    onSubmit(formData);
  };
 
  // Input change handler
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };
 
  // Button click handler
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log('Button clicked', e.currentTarget);
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" onChange={handleChange} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

Best Practice 7: Avoid Type Assertions

❌ Don't

const value = JSON.parse(jsonString) as User; // Unsafe!

✅ Do

import { z } from 'zod';
 
// Define schema with Zod
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});
 
type User = z.infer<typeof UserSchema>;
 
function parseUser(jsonString: string): User {
  const parsed = JSON.parse(jsonString);
  return UserSchema.parse(parsed); // Runtime validation!
}

Best Practice 8: Discriminated Unions

Use discriminated unions for complex state management.

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };
 
function DataComponent() {
  const [state, setState] = useState<RequestState<User>>({ status: 'idle' });
 
  // TypeScript knows exactly what properties are available for each status
  if (state.status === 'loading') {
    return <div>Loading...</div>;
  }
 
  if (state.status === 'error') {
    return <div>Error: {state.error}</div>; // TypeScript knows 'error' exists
  }
 
  if (state.status === 'success') {
    return <div>{state.data.name}</div>; // TypeScript knows 'data' exists
  }
 
  return <button onClick={() => setState({ status: 'loading' })}>Load Data</button>;
}

Conclusion

TypeScript best practices help you:

Avoid bugs: Type safety catches errors early
More maintainable code: Self-documenting code
Better developer experience: Autocomplete and IntelliSense
Easy refactoring: Confidence when changing code

Key Takeaways

  1. Use interfaces for component props
  2. Leverage generic types for reusable components
  3. Use union types for variant props
  4. Take advantage of utility types (Partial, Pick, Omit, Record)
  5. Type custom hooks properly
  6. Type event handlers correctly
  7. Avoid type assertions, use validation instead
  8. Use discriminated unions for complex state

Next Steps

  • Practice these patterns in your projects
  • Explore more advanced TypeScript features
  • Check out the TypeScript Handbook

Happy coding! 🚀

Cong Dinh

Cong Dinh

Technology Consultant | Trainer | Solution Architect

With over 10 years of experience in web development and cloud architecture, I help businesses build modern and sustainable technology solutions. Expertise: Next.js, TypeScript, AWS, and Solution Architecture.

Related Posts