Asynchronous State Management Patterns

1. Loading State Patterns and Boolean Flags

Pattern State Structure Use Case Pros/Cons
Boolean flag const [isLoading, setIsLoading] Simple single async operation ✅ Simple, clear
❌ Can't track multiple operations
Status enum 'idle' | 'loading' | 'success' | 'error' Single operation with multiple states ✅ Mutually exclusive states
❌ Verbose for multiple operations
Multiple flags {isLoading, isRefreshing, isLoadingMore} Different types of loading states ✅ Granular control
❌ Can have invalid combinations
Request ID tracking const [loadingIds, setLoadingIds] Multiple concurrent operations ✅ Track individual requests
❌ More complex state management
Pattern 1: Boolean loading flag
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setIsLoading(false);
      })
      .catch(() => setIsLoading(false));
  }, [userId]);

  if (isLoading) return <Spinner />;
  return <div>{user?.name}</div>;
}
Pattern 2: Status enum (recommended)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [status, setStatus] = useState('idle');

  useEffect(() => {
    setStatus('loading');
    
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setStatus('success');
      })
      .catch(() => setStatus('error'));
  }, [userId]);

  if (status === 'loading') return <Spinner />;
  if (status === 'error') return <Error />;
  return <div>{user?.name}</div>;
}

Example: Multiple loading states for different operations

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [loadingStates, setLoadingStates] = useState({
    initial: false,
    refreshing: false,
    loadingMore: false,
    deleting: new Set() // Track which items are being deleted
  });

  const fetchInitial = async () => {
    setLoadingStates(prev => ({ ...prev, initial: true }));
    const data = await fetch('/api/todos').then(r => r.json());
    setTodos(data);
    setLoadingStates(prev => ({ ...prev, initial: false }));
  };

  const refresh = async () => {
    setLoadingStates(prev => ({ ...prev, refreshing: true }));
    const data = await fetch('/api/todos').then(r => r.json());
    setTodos(data);
    setLoadingStates(prev => ({ ...prev, refreshing: false }));
  };

  const deleteTodo = async (id) => {
    setLoadingStates(prev => ({
      ...prev,
      deleting: new Set([...prev.deleting, id])
    }));
    
    await fetch(`/api/todos/${id}`, { method: 'DELETE' });
    
    setTodos(prev => prev.filter(t => t.id !== id));
    setLoadingStates(prev => {
      const newDeleting = new Set(prev.deleting);
      newDeleting.delete(id);
      return { ...prev, deleting: newDeleting };
    });
  };

  return (
    <div>
      {loadingStates.initial && <Spinner />}
      {loadingStates.refreshing && <RefreshIndicator />}
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          isDeleting={loadingStates.deleting.has(todo.id)}
          onDelete={() => deleteTodo(todo.id)}
        />
      ))}
    </div>
  );
}

2. Error State Handling and Error Boundaries

Approach Implementation Scope Best For
Local error state const [error, setError] = useState(null) Component level Recoverable errors, form validation, API errors
Error Boundaries Class component with componentDidCatch Component tree Unexpected errors, render errors, fallback UI
Global error state Context or state management library Application wide Toast notifications, centralized error logging
useErrorHandler hook Custom hook combining boundaries and state Flexible Reusable error handling logic

Example: Comprehensive error state management

// Pattern 1: Local error state with detailed error info
function DataFetcher() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('idle');

  const fetchData = async () => {
    setStatus('loading');
    setError(null); // Clear previous errors
    
    try {
      const response = await fetch('/api/data');
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      const json = await response.json();
      setData(json);
      setStatus('success');
    } catch (err) {
      setError({
        message: err.message,
        timestamp: new Date().toISOString(),
        type: err.name,
        // Store additional context
        context: { url: '/api/data' }
      });
      setStatus('error');
    }
  };

  const retry = () => {
    setError(null);
    fetchData();
  };

  if (status === 'loading') return <Spinner />;
  
  if (error) {
    return (
      <div className="error">
        <h3>Error: {error.message}</h3>
        <p>Time: {error.timestamp}</p>
        <button onClick={retry}>Retry</button>
      </div>
    );
  }

  return <div>{JSON.stringify(data)}</div>;
}

Example: Error Boundary for catching render errors

import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null, errorInfo: null };
  }

  static getDerivedStateFromError(error) {
    // Update state so next render shows fallback UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // Log error to error reporting service
    console.error('Error caught by boundary:', error, errorInfo);
    
    this.setState({
      error: error,
      errorInfo: errorInfo
    });

    // Send to error tracking service (e.g., Sentry)
    // logErrorToService(error, errorInfo);
  }

  resetError = () => {
    this.setState({ hasError: false, error: null, errorInfo: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <details>
            <summary>Error details</summary>
            <pre>{this.state.error?.toString()}</pre>
            <pre>{this.state.errorInfo?.componentStack}</pre>
          </details>
          <button onClick={this.resetError}>Try Again</button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  );
}
Error Boundary Limitations:
  • Only catches errors in render, lifecycle methods, and constructors of child components
  • Does NOT catch errors in: event handlers, async code, server-side rendering, errors in boundary itself
  • For event handlers, use try/catch and local error state
  • For async operations, use Promise .catch() or try/catch with async/await

3. Success State and Data Management

Pattern State Design Purpose Example
Data + timestamp {data, lastUpdated} Track data freshness Cache invalidation, stale-while-revalidate
Optimistic updates {data, optimisticData} Instant UI feedback Social media likes, todo toggles
Paginated data {items, hasMore, nextCursor} Infinite scroll, pagination Feed scrolling, search results
Normalized data {byId: {}, allIds: []} Efficient updates, no duplication Redux-style entity management

Example: Success state with metadata

function useFetchWithMetadata(url) {
  const [state, setState] = useState({
    data: null,
    status: 'idle',
    error: null,
    // Metadata
    lastUpdated: null,
    stale: false,
    refetchCount: 0
  });

  const fetchData = async (isRefetch = false) => {
    setState(prev => ({
      ...prev,
      status: 'loading',
      error: null,
      refetchCount: isRefetch ? prev.refetchCount + 1 : 0
    }));

    try {
      const response = await fetch(url);
      const data = await response.json();
      
      setState({
        data,
        status: 'success',
        error: null,
        lastUpdated: Date.now(),
        stale: false,
        refetchCount: state.refetchCount
      });
    } catch (error) {
      setState(prev => ({
        ...prev,
        status: 'error',
        error: error.message
      }));
    }
  };

  // Mark data as stale after 5 minutes
  useEffect(() => {
    if (state.lastUpdated) {
      const timer = setTimeout(() => {
        setState(prev => ({ ...prev, stale: true }));
      }, 5 * 60 * 1000);
      
      return () => clearTimeout(timer);
    }
  }, [state.lastUpdated]);

  return {
    ...state,
    fetch: fetchData,
    refetch: () => fetchData(true)
  };
}

4. useReducer for Complex Async State Machines

Action Type State Transition Payload Use Case
FETCH_START idle → loading None Initiate async operation
FETCH_SUCCESS loading → success {data} Store successful result
FETCH_ERROR loading → error {error} Store error information
REFETCH success/error → loading None Retry or refresh data

Example: useReducer for async state machine

const asyncReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_START':
      return {
        ...state,
        status: 'loading',
        error: null
      };
    
    case 'FETCH_SUCCESS':
      return {
        status: 'success',
        data: action.payload,
        error: null
      };
    
    case 'FETCH_ERROR':
      return {
        ...state,
        status: 'error',
        error: action.payload
      };
    
    case 'REFETCH':
      return {
        ...state,
        status: 'loading',
        error: null
        // Keep previous data during refetch
      };
    
    case 'RESET':
      return {
        status: 'idle',
        data: null,
        error: null
      };
    
    default:
      return state;
  }
};

function useAsyncData(fetchFn) {
  const [state, dispatch] = useReducer(asyncReducer, {
    status: 'idle',
    data: null,
    error: null
  });

  const execute = async (...args) => {
    dispatch({ type: 'FETCH_START' });
    
    try {
      const data = await fetchFn(...args);
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
      return { success: true, data };
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
      return { success: false, error };
    }
  };

  const refetch = async (...args) => {
    dispatch({ type: 'REFETCH' });
    return execute(...args);
  };

  return {
    ...state,
    execute,
    refetch,
    reset: () => dispatch({ type: 'RESET' })
  };
}

// Usage
function UserList() {
  const { data, status, error, execute } = useAsyncData(
    async () => {
      const res = await fetch('/api/users');
      return res.json();
    }
  );

  useEffect(() => {
    execute();
  }, []);

  if (status === 'loading') return <Spinner />;
  if (status === 'error') return <Error message={error} />;
  if (status === 'success') return <List items={data} />;
  return null;
}
useReducer Benefits for Async:
  • Centralized state transitions - all async states managed in one place
  • Predictable state updates - reducer enforces valid state transitions
  • Easier testing - reducer is pure function, testable in isolation
  • Better for complex flows - multi-step operations, conditional transitions
  • Time-travel debugging - action history for debugging state changes

5. Promise-based State Updates and Race Conditions

Problem Cause Solution Implementation
Race condition Multiple async operations complete out of order Request ID tracking or AbortController Track latest request, ignore stale responses
Stale closure Callback captures old state value Functional setState updates setState(prev => ...)
Memory leak setState on unmounted component Cleanup with mounted flag Check if mounted before setState
Multiple triggers Rapid user actions trigger multiple requests Debounce or cancel previous request AbortController + debounce

Example: Preventing race conditions with request ID

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    // Create unique ID for this request
    const requestId = Date.now();
    let isLatestRequest = true;

    const searchData = async () => {
      setIsLoading(true);

      // Simulate variable network delay
      await new Promise(resolve => setTimeout(resolve, Math.random() * 2000));

      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();

      // Only update state if this is still the latest request
      if (isLatestRequest) {
        setResults(data);
        setIsLoading(false);
      }
    };

    searchData();

    // Cleanup - mark this request as stale
    return () => {
      isLatestRequest = false;
    };
  }, [query]);

  return <div>{/* render results */}</div>;
}

// Better: Using ref to track latest request ID
function SearchResultsBetter({ query }) {
  const [results, setResults] = useState([]);
  const latestRequestId = useRef(0);

  useEffect(() => {
    const requestId = ++latestRequestId.current;

    const searchData = async () => {
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();

      // Only update if this is the latest request
      if (requestId === latestRequestId.current) {
        setResults(data);
      }
    };

    searchData();
  }, [query]);

  return <div>{/* render results */}</div>;
}

Example: Preventing memory leaks on unmounted components

function DataComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      try {
        const response = await fetch('/api/data');
        const json = await response.json();
        
        // Only update state if component is still mounted
        if (isMounted) {
          setData(json);
        }
      } catch (error) {
        if (isMounted) {
          console.error(error);
        }
      }
    };

    fetchData();

    // Cleanup function sets flag to false
    return () => {
      isMounted = false;
    };
  }, []);

  return <div>{data?.value}</div>;
}

// Custom hook to check if mounted
function useIsMounted() {
  const isMounted = useRef(false);

  useEffect(() => {
    isMounted.current = true;
    return () => {
      isMounted.current = false;
    };
  }, []);

  return isMounted;
}

// Usage with custom hook
function SafeComponent() {
  const [data, setData] = useState(null);
  const isMounted = useIsMounted();

  const fetchData = async () => {
    const response = await fetch('/api/data');
    const json = await response.json();
    
    if (isMounted.current) {
      setData(json);
    }
  };

  return <div>{data?.value}</div>;
}

6. Abort Controllers and Cleanup for Async Operations

Technique API Purpose Browser Support
AbortController new AbortController() Cancel in-flight fetch requests All modern browsers
AbortSignal controller.signal Pass cancellation signal to fetch All modern browsers
controller.abort() Trigger cancellation Cancel ongoing request All modern browsers
Cleanup function Return from useEffect Abort when component unmounts or deps change React API

Example: AbortController with fetch

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const abortController = new AbortController();

    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: abortController.signal // Pass signal to fetch
        });

        if (!response.ok) {
          throw new Error('Failed to fetch');
        }

        const data = await response.json();
        setUser(data);
      } catch (err) {
        // Don't update state if request was aborted
        if (err.name === 'AbortError') {
          console.log('Fetch aborted');
          return;
        }
        setError(err.message);
      }
    };

    fetchUser();

    // Cleanup: abort fetch when component unmounts or userId changes
    return () => {
      abortController.abort();
    };
  }, [userId]);

  if (error) return <Error message={error} />;
  if (!user) return <Loading />;
  return <div>{user.name}</div>;
}

Example: Reusable hook with AbortController

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!url) return;

    const abortController = new AbortController();
    
    const fetchData = async () => {
      setIsLoading(true);
      setError(null);

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

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

        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        if (!abortController.signal.aborted) {
          setIsLoading(false);
        }
      }
    };

    fetchData();

    return () => abortController.abort();
  }, [url, JSON.stringify(options)]);

  return { data, error, isLoading };
}

// Usage
function MyComponent() {
  const [userId, setUserId] = useState(1);
  const { data, error, isLoading } = useFetch(`/api/users/${userId}`);

  // When userId changes, previous fetch is automatically aborted
  return (
    <div>
      <button onClick={() => setUserId(id => id + 1)}>Next User</button>
      {isLoading && <Spinner />}
      {error && <Error message={error} />}
      {data && <User data={data} />}
    </div>
  );
}

Example: Multiple AbortControllers for different operations

function Dashboard() {
  const [users, setUsers] = useState([]);
  const [posts, setPosts] = useState([]);
  const [comments, setComments] = useState([]);

  useEffect(() => {
    // Separate abort controller for each request
    const usersController = new AbortController();
    const postsController = new AbortController();
    const commentsController = new AbortController();

    // Fetch all data in parallel
    Promise.all([
      fetch('/api/users', { signal: usersController.signal })
        .then(r => r.json())
        .then(setUsers)
        .catch(err => {
          if (err.name !== 'AbortError') console.error('Users error:', err);
        }),
      
      fetch('/api/posts', { signal: postsController.signal })
        .then(r => r.json())
        .then(setPosts)
        .catch(err => {
          if (err.name !== 'AbortError') console.error('Posts error:', err);
        }),
      
      fetch('/api/comments', { signal: commentsController.signal })
        .then(r => r.json())
        .then(setComments)
        .catch(err => {
          if (err.name !== 'AbortError') console.error('Comments error:', err);
        })
    ]);

    // Cleanup: abort all requests
    return () => {
      usersController.abort();
      postsController.abort();
      commentsController.abort();
    };
  }, []);

  return <div>{/* render data */}</div>;
}
AbortController Best Practices:
  • Always check for AbortError in catch blocks to avoid logging cancelled requests
  • Create new AbortController for each request - don't reuse controllers
  • Return cleanup function from useEffect to abort on unmount/dep changes
  • Use with debouncing for search inputs to cancel outdated requests
  • Not just for fetch - can be used with any API that accepts AbortSignal

Async State Management Summary:

  • Status enums - Use 'idle' | 'loading' | 'success' | 'error' for clarity
  • Multiple loading states - Track different operations separately (initial, refresh, loadMore)
  • Error boundaries - Catch render errors; use try/catch for async/events
  • Error metadata - Store error message, timestamp, context for debugging
  • useReducer for complex flows - State machines for multi-step async operations
  • Race condition prevention - Use request IDs or AbortController
  • Memory leak prevention - Check isMounted flag before setState
  • AbortController - Cancel stale requests on unmount or dependency changes
  • Functional updates - Use setState(prev => ...) to avoid stale closures
  • Success metadata - Track lastUpdated, stale status for cache management