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
AbortErrorin 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