Doing React the Right Way - 7/10

Advanced React Patterns and Performance Optimization

You’ve mastered React’s fundamentals: components, state, effects, and context. You can build functional applications that handle complex data flow and user interactions. Now it’s time to explore the advanced patterns and techniques that separate hobbyist projects from production-ready applications.

Today we’ll cover error boundaries for resilient applications, custom hooks for reusable logic, performance optimization techniques, code splitting for efficient loading, and portals for flexible DOM rendering. These patterns address the challenges that emerge when applications grow beyond simple demos to serve real users at scale.

Error Boundaries: Building Resilient Applications

In a typical React application, when any component throws an error during rendering, the entire application unmounts, leaving users with a blank white screen. This is unacceptable for production applications—it’s like having your car’s engine fail because the radio stopped working.

Error Boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of crashing the entire application.

Implementing Error Boundaries

Error boundaries must be class components (one of the few remaining use cases for them):

import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
}

interface State {
  hasError: boolean;
  error?: Error;
}

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  // This lifecycle method catches errors during rendering
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  // This lifecycle method is for side effects like error logging
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Error boundary caught an error:", error, errorInfo);

    // Call optional error handler
    this.props.onError?.(error, errorInfo);

    // In production, you'd send this to an error reporting service
    // reportErrorToService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Render fallback UI
      if (this.props.fallback) {
        return this.props.fallback;
      }

      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <details style={{ whiteSpace: "pre-wrap" }}>
            <summary>Error details</summary>
            {this.state.error?.toString()}
          </details>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

Strategic Error Boundary Placement

Place error boundaries around logical sections of your application to contain failures:

function App() {
  return (
    <div className="app">
      {/* Header errors won't affect main content */}
      <ErrorBoundary fallback={<div>Header unavailable</div>}>
        <Header />
      </ErrorBoundary>

      <main className="main-content">
        {/* Separate boundaries for independent features */}
        <ErrorBoundary fallback={<div>User profile unavailable</div>}>
          <UserProfile />
        </ErrorBoundary>

        <ErrorBoundary fallback={<div>Dashboard unavailable</div>}>
          <Dashboard />
        </ErrorBoundary>
      </main>

      {/* Footer errors won't affect other components */}
      <ErrorBoundary fallback={<div>Footer unavailable</div>}>
        <Footer />
      </ErrorBoundary>
    </div>
  );
}

This approach ensures that if one section fails, the rest of your application remains functional and accessible to users.

Custom Hooks: Reusable Logic Patterns

As applications grow, you’ll notice repeated patterns across components: data fetching with loading states, form validation, local storage synchronization, and more. Custom hooks let you extract this logic into reusable, testable functions.

The useDebounce Hook

A common pattern is delaying API calls until users stop typing:

// hooks/useDebounce.ts
import { useState, useEffect } from "react";

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup function cancels the timeout if value changes
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// Usage in a component
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState("");
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      // This only runs 500ms after user stops typing
      performSearch(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  return (
    <input
      type="text"
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search..."
    />
  );
}

The useLocalStorage Hook

Synchronizing component state with localStorage:

// hooks/useLocalStorage.ts
import { useState, useEffect } from "react";

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // Get initial value from localStorage or use provided initial value
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  const setValue = (value: T | ((prev: T) => T)) => {
    try {
      // Allow value to be a function for same API as useState
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue];
}

// Usage
function SettingsComponent() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
  const [username, setUsername] = useLocalStorage("username", "");

  return (
    <div>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        Current theme: {theme}
      </button>
      <input
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="Username"
      />
    </div>
  );
}

The useApi Hook

A comprehensive data fetching hook with loading states and error handling:

// hooks/useApi.ts
import { useState, useEffect, useRef } from "react";

interface UseApiState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

export function useApi<T>(
  url: string,
  options?: RequestInit
): UseApiState<T> & { refetch: () => void } {
  const [state, setState] = useState<UseApiState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  const abortControllerRef = useRef<AbortController>();

  const fetchData = async () => {
    try {
      // Cancel any existing request
      abortControllerRef.current?.abort();

      // Create new abort controller
      abortControllerRef.current = new AbortController();

      setState((prev) => ({ ...prev, loading: true, error: null }));

      const response = await fetch(url, {
        ...options,
        signal: abortControllerRef.current.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data: T = await response.json();
      setState({ data, loading: false, error: null });
    } catch (error) {
      if (error.name !== "AbortError") {
        setState((prev) => ({
          ...prev,
          loading: false,
          error: error as Error,
        }));
      }
    }
  };

  useEffect(() => {
    fetchData();

    // Cleanup function
    return () => {
      abortControllerRef.current?.abort();
    };
  }, [url, JSON.stringify(options)]);

  return {
    ...state,
    refetch: fetchData,
  };
}

// Usage
function UserList() {
  const { data: users, loading, error, refetch } = useApi<User[]>("/api/users");

  if (loading) return <div>Loading users...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <button onClick={refetch}>Refresh</button>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Performance Optimization Techniques

Important: Only optimize when you have measured performance problems. Use React DevTools Profiler to identify bottlenecks before applying these techniques.

React.memo for Component Memoization

Prevents unnecessary re-renders when props haven’t changed:

interface UserCardProps {
  user: User;
  onEdit: (user: User) => void;
}

// Without memo: re-renders every time parent renders
const UserCard = ({ user, onEdit }: UserCardProps) => {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <button onClick={() => onEdit(user)}>Edit</button>
    </div>
  );
};

// With memo: only re-renders when user or onEdit changes
const MemoizedUserCard = React.memo(UserCard);

// Custom comparison for complex props
const UserCardWithCustomMemo = React.memo(UserCard, (prevProps, nextProps) => {
  return (
    prevProps.user.id === nextProps.user.id &&
    prevProps.user.name === nextProps.user.name &&
    prevProps.onEdit === nextProps.onEdit
  );
});

useCallback for Function Memoization

Prevents creating new function instances on every render:

function UserList({ users }: { users: User[] }) {
  const [selectedUsers, setSelectedUsers] = useState<number[]>([]);

  // Without useCallback: new function created on every render
  const handleUserSelect = (userId: number) => {
    setSelectedUsers((prev) =>
      prev.includes(userId)
        ? prev.filter((id) => id !== userId)
        : [...prev, userId]
    );
  };

  // With useCallback: same function instance between renders
  const handleUserSelectMemoized = useCallback((userId: number) => {
    setSelectedUsers((prev) =>
      prev.includes(userId)
        ? prev.filter((id) => id !== userId)
        : [...prev, userId]
    );
  }, []); // Empty dependency array because function doesn't depend on any values

  return (
    <div>
      {users.map((user) => (
        <MemoizedUserCard
          key={user.id}
          user={user}
          onEdit={handleUserSelectMemoized} // Same function reference
        />
      ))}
    </div>
  );
}

useMemo for Expensive Calculations

Memoizes the result of expensive computations:

function DataAnalysis({ data }: { data: DataPoint[] }) {
  const [filter, setFilter] = useState("");

  // Expensive calculation that should only run when dependencies change
  const processedData = useMemo(() => {
    console.log("Processing data..."); // This should only log when data or filter changes

    return data
      .filter((point) => point.label.includes(filter))
      .sort((a, b) => b.value - a.value)
      .slice(0, 100); // Top 100 items
  }, [data, filter]); // Only recalculate when data or filter changes

  const statistics = useMemo(() => {
    return {
      average:
        processedData.reduce((sum, point) => sum + point.value, 0) /
        processedData.length,
      max: Math.max(...processedData.map((point) => point.value)),
      min: Math.min(...processedData.map((point) => point.value)),
    };
  }, [processedData]);

  return (
    <div>
      <input
        type="text"
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter data..."
      />
      <div>
        <p>Average: {statistics.average.toFixed(2)}</p>
        <p>Max: {statistics.max}</p>
        <p>Min: {statistics.min}</p>
      </div>
      <ul>
        {processedData.map((point) => (
          <li key={point.id}>
            {point.label}: {point.value}
          </li>
        ))}
      </ul>
    </div>
  );
}

Code Splitting with Lazy Loading

Split your application bundle into smaller chunks that load on demand:

import React, { Suspense, lazy } from "react";
import { Routes, Route } from "react-router-dom";
import LoadingSpinner from "./components/LoadingSpinner";

// Eagerly loaded components for immediate routes
import HomePage from "./pages/HomePage";
import Navigation from "./components/Navigation";

// Lazy loaded components for routes that aren't immediately needed
const Dashboard = lazy(() => import("./pages/Dashboard"));
const UserProfile = lazy(() => import("./pages/UserProfile"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));

// You can also add loading delays to prevent flash of loading state
const Settings = lazy(() =>
  Promise.all([
    import("./pages/Settings"),
    new Promise((resolve) => setTimeout(resolve, 300)), // Minimum 300ms loading
  ]).then(([moduleExports]) => moduleExports)
);

function App() {
  return (
    <div className="app">
      <Navigation />

      <main className="main-content">
        <Suspense fallback={<LoadingSpinner />}>
          <Routes>
            <Route path="/" element={<HomePage />} />
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/profile" element={<UserProfile />} />
            <Route path="/settings" element={<Settings />} />
            <Route path="/admin" element={<AdminPanel />} />
          </Routes>
        </Suspense>
      </main>
    </div>
  );
}

This approach dramatically reduces initial bundle size. Users only download code for the routes they visit.

Portals for Flexible Rendering

Portals allow you to render components outside their normal DOM hierarchy, useful for modals, tooltips, and overlays:

// components/Modal.tsx
import { ReactNode, useEffect } from "react";
import ReactDOM from "react-dom";

interface ModalProps {
  children: ReactNode;
  isOpen: boolean;
  onClose: () => void;
}

function Modal({ children, isOpen, onClose }: ModalProps) {
  useEffect(() => {
    if (isOpen) {
      // Prevent body scrolling when modal is open
      document.body.style.overflow = "hidden";

      // Close modal on escape key
      const handleEscape = (e: KeyboardEvent) => {
        if (e.key === "Escape") {
          onClose();
        }
      };

      document.addEventListener("keydown", handleEscape);

      return () => {
        document.body.style.overflow = "unset";
        document.removeEventListener("keydown", handleEscape);
      };
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  // Create portal to render modal outside component hierarchy
  return ReactDOM.createPortal(
    <div
      className="modal-overlay"
      onClick={(e) => {
        // Close when clicking overlay
        if (e.target === e.currentTarget) {
          onClose();
        }
      }}
    >
      <div className="modal-content">
        <button
          className="modal-close"
          onClick={onClose}
          aria-label="Close modal"
        >
          ×
        </button>
        {children}
      </div>
    </div>,
    document.body // Render directly into body
  );
}

// Usage
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div className="app">
      <header>My Application</header>

      <main>
        <button onClick={() => setIsModalOpen(true)}>Open Modal</button>

        {/* Modal renders outside this component tree but behaves normally */}
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
          <h2>Modal Content</h2>
          <p>This modal is rendered outside the normal component hierarchy.</p>
        </Modal>
      </main>
    </div>
  );
}

Add the corresponding CSS:

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  border-radius: 8px;
  padding: 2rem;
  max-width: 500px;
  max-height: 80vh;
  overflow-y: auto;
  position: relative;
}

.modal-close {
  position: absolute;
  top: 1rem;
  right: 1rem;
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
}

Architectural Best Practices

Component Organization

Structure your components for maintainability:

src/
├── components/          # Reusable UI components
│   ├── common/         # Basic components (Button, Input, Modal)
│   ├── forms/          # Form-related components
│   └── layout/         # Layout components (Header, Sidebar)
├── pages/              # Page-level components
├── hooks/              # Custom hooks
├── contexts/           # React contexts
├── services/           # API and external service interfaces
├── utils/              # Pure utility functions
├── types/              # TypeScript type definitions
└── constants/          # Application constants

Error Handling Strategy

Implement comprehensive error handling:

// services/api.ts
class ApiError extends Error {
  constructor(message: string, public status: number, public endpoint: string) {
    super(message);
    this.name = "ApiError";
  }
}

export class ApiService {
  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    try {
      const response = await fetch(`${API_BASE_URL}${endpoint}`, {
        headers: {
          "Content-Type": "application/json",
          ...options.headers,
        },
        ...options,
      });

      if (!response.ok) {
        throw new ApiError(
          `Request failed: ${response.statusText}`,
          response.status,
          endpoint
        );
      }

      return await response.json();
    } catch (error) {
      if (error instanceof ApiError) {
        throw error;
      }
      throw new ApiError("Network error", 0, endpoint);
    }
  }

  async getUsers(): Promise<User[]> {
    return this.request<User[]>("/users");
  }
}

Production Readiness Checklist

Before deploying React applications to production:

  • Error Boundaries: Implemented around logical sections
  • Performance: Measured and optimized bottlenecks
  • Code Splitting: Lazy loading for non-critical routes
  • Error Logging: Integrated with monitoring services
  • Accessibility: ARIA labels, keyboard navigation, screen reader support
  • Loading States: Proper feedback during async operations
  • Error States: Graceful handling of all failure scenarios
  • TypeScript: Full type coverage for maintainability

Conclusion

These advanced patterns and techniques transform React applications from functional demos into robust, scalable production systems. The key is applying them judiciously—not every component needs memoization, not every route needs lazy loading, and not every state needs complex optimization.

Understanding these patterns gives you the toolkit to handle edge cases, performance requirements, and architectural challenges that arise in real-world applications. You can now build React applications that not only work but scale, perform well, and provide excellent user experiences even under demanding conditions.

The journey from learning React syntax to architecting production applications is complete. You now have both the fundamental understanding and the advanced techniques needed to build professional-grade React applications that serve real users at scale.