TypeScript Best Practices for React Developers

By Sarah Chen
15 min read
TypeScript Best Practices for React Developers

TypeScript Best Practices for React Developers

TypeScript has become the standard for building robust React applications. In this comprehensive guide, we'll explore best practices that will make you a TypeScript pro.

Why TypeScript with React?

TypeScript adds static typing to JavaScript, catching errors at compile time rather than runtime.

💡NOTE

Studies show that TypeScript can prevent up to 15% of bugs before they reach production!

Basic Type Annotations

Component Props

Always type your component props:

TYPESCRIPT
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}
 
function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) {
  return (
    <button 
      onClick={onClick} 
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {label}
    </button>
  );
}

State Types

Type your state correctly:

TYPESCRIPT
import { useState } from 'react';
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
function UserProfile() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  
  return (
    <div>
      {user && <p>{user.name}</p>}
    </div>
  );
}

Advanced Patterns

Generic Components

Create reusable generic components:

TYPESCRIPT
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
}
 
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}
 
// Usage
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

Discriminated Unions

Handle different states elegantly:

TYPESCRIPT
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };
 
function DataComponent() {
  const [state, setState] = useState<FetchState<User>>({ status: 'idle' });
  
  if (state.status === 'loading') {
    return <Spinner />;
  }
  
  if (state.status === 'error') {
    return <Error message={state.error} />;
  }
  
  if (state.status === 'success') {
    return <UserCard user={state.data} />;
  }
  
  return <button onClick={fetchUser}>Load User</button>;
}
SUCCESS

Discriminated unions provide excellent type narrowing and IDE support!

Utility Types

Leverage TypeScript's utility types:

TYPESCRIPT
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
// Pick specific properties
type UserPublic = Pick<User, 'id' | 'name' | 'email'>;
 
// Omit sensitive data
type UserSafe = Omit<User, 'password'>;
 
// Make all properties optional
type UserPartial = Partial<User>;
 
// Make all properties required
type UserRequired = Required<UserPartial>;
 
// Make all properties readonly
type UserReadonly = Readonly<User>;

Custom Hooks with TypeScript

Typed Custom Hook

TYPESCRIPT
interface UseLocalStorageReturn<T> {
  value: T;
  setValue: (value: T) => void;
  removeValue: () => void;
}
 
function useLocalStorage<T>(
  key: string,
  initialValue: T
): UseLocalStorageReturn<T> {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  const setStoredValue = (newValue: T) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };
  
  const removeValue = () => {
    setValue(initialValue);
    localStorage.removeItem(key);
  };
  
  return { value, setValue: setStoredValue, removeValue };
}

Fetch Hook

TYPESCRIPT
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => Promise<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);
      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 };
}

Event Handlers

Type event handlers correctly:

TYPESCRIPT
import { ChangeEvent, FormEvent, MouseEvent } from 'react';
 
function Form() {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };
  
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // Submit logic
  };
  
  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    console.log(e.clientX, e.clientY);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

Context API with TypeScript

Type-Safe Context

TYPESCRIPT
import { createContext, useContext, ReactNode } from 'react';
 
interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}
 
const AuthContext = createContext<AuthContextType | undefined>(undefined);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  // Implementation
  const value: AuthContextType = {
    user,
    login,
    logout,
    isAuthenticated: !!user,
  };
  
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

Common Pitfalls to Avoid

1. Using any

TYPESCRIPT
// ❌ Bad
const data: any = fetchData();
 
// ✅ Good
interface ApiResponse {
  data: User[];
  total: number;
}
const data: ApiResponse = await fetchData();

2. Not Using Strict Mode

Enable strict mode in tsconfig.json:

JSON
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitAny": true
  }
}
💡TIP

Always enable strict mode for maximum type safety!

3. Ignoring TypeScript Errors

TYPESCRIPT
// ❌ Bad
// @ts-ignore
const result = riskyOperation();
 
// ✅ Good
try {
  const result = riskyOperation();
} catch (error) {
  handleError(error as Error);
}

Type Guards

Create custom type guards:

TYPESCRIPT
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj
  );
}
 
function processData(data: unknown) {
  if (isUser(data)) {
    // TypeScript knows data is User here
    console.log(data.name);
  }
}

Conclusion

TypeScript significantly improves the development experience and code quality in React applications. By following these best practices, you'll write more maintainable and bug-free code.

Key Takeaways

  • Always type your props and state
  • Use utility types to transform existing types
  • Create reusable generic components
  • Enable strict mode
  • Avoid any type
  • Use discriminated unions for complex states

Happy typing! 💪

About the Author

Written by Sarah Chen