TypeScript Best Practices for React Development

Essential TypeScript patterns and best practices for building robust React applications with better type safety and developer experience.

Nosgnoh
October 8, 2024
8 minute read
TypeScript Best Practices for React Development

TypeScript Best Practices for React Development

TypeScript has become the de facto standard for modern React development. Let's explore essential patterns and best practices that will make your React applications more robust and maintainable.

Component Typing Fundamentals

Functional Component Types

import { FC, ReactNode } from 'react';

// Method 1: Using FC (Function Component)
const Button: FC<ButtonProps> = ({ children, onClick, variant = 'primary' }) => {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
};

// Method 2: Explicit return type (preferred)
function Button({ children, onClick, variant = 'primary' }: ButtonProps): JSX.Element {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
}

interface ButtonProps {
  children: ReactNode;
  onClick: () => void;
  variant?: 'primary' | 'secondary' | 'danger';
}

Props with Generics

interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => ReactNode;
  keyExtractor: (item: T) => string | number;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Usage
const users = [
  { id: 1, name: 'John', email: 'john@example.com' },
  { id: 2, name: 'Jane', email: 'jane@example.com' }
];

<List
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  )}
/>

Advanced Hook Typing

Custom Hook with Generic Types

import { useState, useEffect } from 'react';

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

function useApi<T>(url: string): UseApiResponse<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = async () => {
    try {
      setLoading(true);
      setError(null);
      const response = await fetch(url);
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result: T = await response.json();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    } finally {
      setLoading(false);
    }
  };

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

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

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

function UserProfile({ userId }: { userId: number }) {
  const { data: user, loading, error } = useApi<User>(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Event Handler Typing

import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react';

interface FormProps {
  onSubmit: (data: FormData) => void;
}

interface FormData {
  name: string;
  email: string;
  message: string;
}

function ContactForm({ onSubmit }: FormProps) {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: ''
  });

  const handleInputChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSubmit(formData);
  };

  const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => {
    console.log('Button clicked', e.currentTarget.name);
  };

  const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter' && e.ctrlKey) {
      // Handle Ctrl+Enter
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleInputChange}
        onKeyDown={handleKeyPress}
      />
      <button type="submit" onClick={handleButtonClick}>
        Submit
      </button>
    </form>
  );
}

Context and State Management

Typed Context

import { createContext, useContext, ReactNode, useReducer } from 'react';

// State types
interface User {
  id: number;
  name: string;
  email: string;
}

interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  loading: boolean;
}

// Action types
type AuthAction = 
  | { type: 'LOGIN_START' }
  | { type: 'LOGIN_SUCCESS'; payload: User }
  | { type: 'LOGIN_FAILURE' }
  | { type: 'LOGOUT' };

// Context type
interface AuthContextType {
  state: AuthState;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// Create context with default value
const AuthContext = createContext<AuthContextType | null>(null);

// Reducer
function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'LOGIN_START':
      return { ...state, loading: true };
    case 'LOGIN_SUCCESS':
      return {
        user: action.payload,
        isAuthenticated: true,
        loading: false
      };
    case 'LOGIN_FAILURE':
      return {
        user: null,
        isAuthenticated: false,
        loading: false
      };
    case 'LOGOUT':
      return {
        user: null,
        isAuthenticated: false,
        loading: false
      };
    default:
      return state;
  }
}

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

export function AuthProvider({ children }: AuthProviderProps) {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isAuthenticated: false,
    loading: false
  });

  const login = async (email: string, password: string) => {
    dispatch({ type: 'LOGIN_START' });
    try {
      const user = await authApi.login(email, password);
      dispatch({ type: 'LOGIN_SUCCESS', payload: user });
    } catch (error) {
      dispatch({ type: 'LOGIN_FAILURE' });
      throw error;
    }
  };

  const logout = () => {
    dispatch({ type: 'LOGOUT' });
  };

  return (
    <AuthContext.Provider value={{ state, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook with proper error handling
export function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Advanced Type Patterns

Conditional Types and Utility Types

// Conditional rendering based on props
interface BaseProps {
  title: string;
}

interface WithLink extends BaseProps {
  href: string;
  onClick?: never;
}

interface WithButton extends BaseProps {
  onClick: () => void;
  href?: never;
}

type CardProps = WithLink | WithButton;

function Card(props: CardProps) {
  if (props.href) {
    return (
      <a href={props.href} className="card">
        <h3>{props.title}</h3>
      </a>
    );
  }

  return (
    <button onClick={props.onClick} className="card">
      <h3>{props.title}</h3>
    </button>
  );
}

// Utility types for form handling
interface ApiUser {
  id: number;
  name: string;
  email: string;
  createdAt: string;
  updatedAt: string;
}

// Create form type by picking only editable fields
type UserFormData = Pick<ApiUser, 'name' | 'email'>;

// Make all fields optional for partial updates
type UserUpdateData = Partial<UserFormData>;

// Omit sensitive fields from public interface
type PublicUser = Omit<ApiUser, 'createdAt' | 'updatedAt'>;

Generic Form Components

type FormField<T> = {
  [K in keyof T]: {
    value: T[K];
    error?: string;
    touched: boolean;
  };
};

interface UseFormOptions<T> {
  initialValues: T;
  validate?: (values: T) => Partial<Record<keyof T, string>>;
  onSubmit: (values: T) => Promise<void> | void;
}

function useForm<T extends Record<string, any>>({
  initialValues,
  validate,
  onSubmit
}: UseFormOptions<T>) {
  const [fields, setFields] = useState<FormField<T>>(
    Object.keys(initialValues).reduce((acc, key) => {
      acc[key as keyof T] = {
        value: initialValues[key as keyof T],
        touched: false
      };
      return acc;
    }, {} as FormField<T>)
  );

  const updateField = <K extends keyof T>(name: K, value: T[K]) => {
    setFields(prev => ({
      ...prev,
      [name]: {
        ...prev[name],
        value,
        touched: true
      }
    }));
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    
    const values = Object.keys(fields).reduce((acc, key) => {
      acc[key as keyof T] = fields[key as keyof T].value;
      return acc;
    }, {} as T);

    if (validate) {
      const errors = validate(values);
      if (Object.keys(errors).length > 0) {
        // Handle validation errors
        return;
      }
    }

    await onSubmit(values);
  };

  return {
    fields,
    updateField,
    handleSubmit
  };
}

Component Composition Patterns

Render Props with TypeScript

interface RenderPropExample<T> {
  data: T[];
  loading: boolean;
  children: (props: { data: T[]; loading: boolean }) => ReactNode;
}

function DataProvider<T>({ data, loading, children }: RenderPropExample<T>) {
  return <>{children({ data, loading })}</>;
}

// Higher-Order Component typing
interface WithLoadingProps {
  loading: boolean;
}

function withLoading<P extends object>(
  Component: React.ComponentType<P>
): React.ComponentType<P & WithLoadingProps> {
  return function WithLoadingComponent(props: P & WithLoadingProps) {
    if (props.loading) {
      return <div>Loading...</div>;
    }
    
    return <Component {...props} />;
  };
}

// Usage
const UserListWithLoading = withLoading(UserList);

Best Practices Summary

1. Prefer Interfaces Over Types for Object Shapes

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

// Use type for unions and computed types
type Status = 'loading' | 'success' | 'error';
type UserKeys = keyof User;

2. Use Strict TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

3. Avoid any - Use Unknown Instead

// Bad
function processData(data: any) {
  return data.someProperty;
}

// Good
function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'someProperty' in data) {
    return (data as { someProperty: any }).someProperty;
  }
  throw new Error('Invalid data structure');
}

4. Use Type Guards

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    typeof (obj as any).id === 'number' &&
    typeof (obj as any).name === 'string'
  );
}

function handleUserData(data: unknown) {
  if (isUser(data)) {
    // TypeScript knows data is User here
    console.log(data.name);
  }
}

Conclusion

TypeScript transforms React development by providing compile-time error checking, better IntelliSense, and improved refactoring capabilities. By following these patterns and best practices, you'll write more maintainable and robust React applications.

Remember: start strict, be explicit with types, and leverage TypeScript's powerful type system to catch errors before they reach production.

Tags

#typescript#react#types#interfaces#generics#best-practices
All Posts
Share this post with your network