Best Practices for Handling Async State in Frontend Apps

Managing asynchronous state is one of the most crucial yet challenging aspects of frontend development. Asynchronous operations, such as fetching data from APIs, processing user inputs, or handling external resources, are core to modern web applications. However, dealing with async state brings its own set of complexities, from managing loading states and error handling to ensuring the UI remains responsive and consistent.

In this article, we’ll dive deep into the best practices for handling async state in frontend apps. Whether you’re building a small single-page application or a large-scale project, mastering async state will improve your application’s performance and make it more robust and user-friendly.

What is Asynchronous State?

Async state refers to state that depends on or is affected by asynchronous operations. These operations might include data fetching, external API calls, or deferred calculations that don’t happen instantly. Unlike synchronous operations, which happen sequentially and immediately, async operations take time to complete. This means your application must handle different states: loading, success, and failure.

For example, consider a scenario where you need to fetch user data from an API. The application must manage the following states:

Loading: When the request is in progress.

Success: When the data has been successfully fetched.

Error: If something goes wrong during the request.

Handling async state requires a clear strategy to ensure your app behaves predictably in all these situations.

1. Proper Loading States and UX Considerations

One of the most critical aspects of managing async state is how the app handles loading states. Users should be informed that a process is happening behind the scenes, so they understand that the app is not frozen or unresponsive. Proper handling of loading states improves the user experience and reduces frustration.

Show Meaningful Feedback During Loading

When an async operation is in progress, provide a clear indication to the user that something is loading. This could be as simple as a loading spinner, a progress bar, or a skeleton screen that mimics the layout of the content being loaded.

import React, { useState, useEffect } from 'react';

function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
async function fetchUserData() {
setLoading(true);
const response = await fetch('https://api.example.com/user');
const data = await response.json();
setUser(data);
setLoading(false);
}
fetchUserData();
}, []);

if (loading) {
return <p>Loading...</p>;
}

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

In this example, the loading state is managed using a loading variable. When the request is in progress, a loading message is shown, providing feedback to the user. This prevents the UI from appearing unresponsive.

Avoid Blocking the UI

While it’s important to show a loading state, you should avoid blocking the entire UI with loading spinners, especially when users can still interact with other parts of the app. Consider using partial loaders (e.g., spinners inside a button or on specific content sections) rather than blocking the entire screen.

This technique keeps the user engaged and allows them to continue interacting with the app, even while waiting for async data to load.

Use Skeleton Screens for Improved Perceived Performance

Skeleton screens are a great way to improve the perceived performance of your app. Instead of showing a generic loading spinner, skeleton screens display a placeholder that resembles the final UI layout. This creates a more visually appealing experience, as users can see the structure of the content they’re waiting for.

function SkeletonProfile() {
return (
<div className="skeleton">
<div className="skeleton-avatar"></div>
<div className="skeleton-text"></div>
<div className="skeleton-text"></div>
</div>
);
}

function UserProfile({ loading, user }) {
if (loading) {
return <SkeletonProfile />;
}

return (
<div>
<img src={user.avatar} alt="User Avatar" />
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}

In this example, while the user data is being fetched, a skeleton UI is displayed, which mimics the structure of the final profile. This gives users a sense of progress even though the data is still loading.

2. Handling Errors Gracefully

Errors are inevitable when dealing with async operations, and how you handle them can make or break the user experience. When something goes wrong—whether it’s a network issue, a server error, or an unexpected result—your app should gracefully manage the failure and give users clear feedback on what happened and what they can do next.

Errors are inevitable when dealing with async operations, and how you handle them can make or break the user experience.

Display Clear Error Messages

When an error occurs, it’s essential to show an informative error message that tells users what went wrong and, if possible, what they can do to fix it. A generic “Something went wrong” message doesn’t provide much help. Instead, consider providing actionable steps or explanations.

import React, { useState, useEffect } from 'react';

function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
async function fetchUserData() {
try {
setLoading(true);
const response = await fetch('https://api.example.com/user');
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUserData();
}, []);

if (loading) {
return <p>Loading...</p>;
}

if (error) {
return <p>Error: {error}</p>;
}

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

In this example, if the API request fails, an error message is displayed. The user is informed that something went wrong, and the application doesn’t break or crash unexpectedly.

Retry Logic for Failed Requests

Implementing a retry mechanism is a great way to handle temporary failures, such as network issues or timeouts. Rather than forcing the user to manually retry, you can automatically attempt to fetch the data again a few times before showing an error.

import React, { useState, useEffect } from 'react';

function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);

useEffect(() => {
async function fetchUserData() {
try {
setLoading(true);
const response = await fetch('https://api.example.com/user');
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const data = await response.json();
setUser(data);
setError(null);
} catch (err) {
if (retryCount < 3) {
setRetryCount(retryCount + 1);
} else {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUserData();
}, [retryCount]);

if (loading) {
return <p>Loading...</p>;
}

if (error) {
return (
<div>
<p>Error: {error}</p>
<button onClick={() => setRetryCount(0)}>Retry</button>
</div>
);
}

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

Here, the application retries the API request up to three times before displaying an error message. Users can also manually retry the request by clicking the “Retry” button. This creates a more resilient experience and handles intermittent network issues more gracefully.

3. Managing Async State with State Management Tools

As your app grows, manually handling async state within individual components can become cumbersome. State management libraries like Redux, Recoil, or MobX offer structured approaches to managing async state across your application.

Using Redux with Thunks for Async Operations

In Redux, handling async operations is typically done with middleware like Redux Thunk. This allows you to dispatch asynchronous actions, keeping your async state management clean and centralized.

Here’s an example of handling async state with Redux Thunk:

// actions.js
export const fetchUser = () => async (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const response = await fetch('https://api.example.com/user');
const data = await response.json();
dispatch({ type: 'FETCH_USER_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_USER_FAILURE', payload: error.message });
}
};

// reducer.js
const initialState = {
user: null,
loading: false,
error: null,
};

export const userReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_USER_REQUEST':
return { ...state, loading: true, error: null };
case 'FETCH_USER_SUCCESS':
return { ...state, loading: false, user: action.payload };
case 'FETCH_USER_FAILURE':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
};

In this example, Redux handles the async state through actions and reducers. By centralizing the logic for fetching data, your app becomes more maintainable, especially as the number of async operations grows.

Using Recoil for Async State

If you’re using Recoil (a state management library for React), managing async state is even more straightforward thanks to Recoil’s support for asynchronous atoms and selectors.

import { atom, selector } from 'recoil';

export const userState = atom({
key: 'userState',
default: null,
});

export const userQuery = selector({
key: 'userQuery',
get: async () => {
const response = await fetch('https://api.example.com/user');
return await response.json();
},
});

Recoil’s selector can handle async requests directly, which simplifies the way you manage async state in React. The component that consumes this selector can handle loading and error states accordingly.

4. Caching Data to Optimize Performance

Fetching data repeatedly from the server can slow down your app and degrade the user experience. Implementing caching strategies can reduce the number of requests and improve performance. You can cache the fetched data either on the client-side or server-side.

Caching with SWR or React Query

SWR and React Query are libraries that help manage data fetching, caching, and synchronization in React applications. Both libraries provide built-in caching mechanisms, reducing the need to manually handle cache logic.

Here’s an example using SWR:

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then((res) => res.json());

function UserProfile() {
const { data, error } = useSWR('https://api.example.com/user', fetcher);

if (error) return <p>Error loading user data.</p>;
if (!data) return <p>Loading...</p>;

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

SWR automatically caches the result of the fetch operation and will reuse the data unless it becomes stale or the request is invalidated. This reduces unnecessary API requests and improves performance, especially in data-heavy applications.

5. Leveraging Suspense for Async State (Future-Proofing)

React’s Suspense is a feature designed to help manage async state and loading states more declaratively. While currently available for code splitting, Suspense for data fetching is expected to be a game-changer in managing async state more elegantly.

Here’s an early example of how Suspense might be used:

<Suspense fallback={<LoadingSpinner />}>
<UserProfile />
</Suspense>

In this future scenario, Suspense will handle async data fetching in a way that simplifies state management, removing the need to manually track loading and error states.

Advanced Strategies for Handling Asynchronous State in Complex Applications

As your frontend application grows in complexity, managing asynchronous state becomes more challenging. While the basic best practices covered earlier can help with simple apps, advanced techniques and patterns are needed to maintain scalability, performance, and maintainability in larger projects. In this section, we’ll explore more advanced strategies to handle async state efficiently, reduce bugs, and ensure that your app can scale without introducing unnecessary complexity.

When dealing with user input that triggers asynchronous requests

1. Debouncing and Throttling Asynchronous Requests

When dealing with user input that triggers asynchronous requests (e.g., search queries, form submissions, or scroll events), sending a request with every keystroke or action can overwhelm the server and degrade performance. Debouncing and throttling are techniques that help reduce the frequency of requests by controlling how often they are sent.

Debouncing: Delays the execution of a function until a specified time has passed since the last action. This is useful for search bars or input fields where you want to wait for the user to finish typing before making an API request.

Throttling: Limits the number of times a function can be called over a specified time interval, ensuring the function executes at regular intervals, no matter how frequently it’s triggered. This is useful for handling events like scrolling or resizing.

Debouncing Example in React

import React, { useState, useEffect } from 'react';

function SearchBar() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState(query);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
}, 500); // 500ms delay

return () => {
clearTimeout(handler);
};
}, [query]);

useEffect(() => {
// Trigger search API call using debouncedQuery
async function fetchSearchResults() {
if (debouncedQuery) {
const response = await fetch(`https://api.example.com/search?q=${debouncedQuery}`);
const results = await response.json();
console.log(results);
}
}
fetchSearchResults();
}, [debouncedQuery]);

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

In this example, the fetchSearchResults function only triggers after the user stops typing for 500 milliseconds, reducing the number of API requests.

Throttling Example in JavaScript

Throttling can be implemented using plain JavaScript for cases like scrolling events:

function throttle(func, limit) {
let lastFunc;
let lastRan;
return function () {
const context = this;
const args = arguments;
if (!lastRan) {
func.apply(context, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function () {
if (Date.now() - lastRan >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
}

window.addEventListener(
'scroll',
throttle(() => {
console.log('Scrolling...');
}, 200) // Throttled scroll event every 200ms
);

Throttling ensures that even if the user scrolls rapidly, the handler is only executed at regular intervals.

2. Optimizing Performance with Suspense and Concurrent Rendering

Asynchronous state management becomes even more powerful when you combine it with React’s Concurrent Mode and Suspense (when fully available for data fetching). Concurrent Mode allows React to work on multiple tasks simultaneously without blocking the user interface, resulting in more fluid and responsive apps.

Suspense for Data Fetching (Future-Proofing)

Suspense for Data Fetching is a feature that will allow developers to declaratively manage async state. This will allow you to automatically show a fallback (e.g., a loading spinner) while waiting for data to resolve, simplifying the logic for handling async states.

While it’s not fully available yet for data fetching, here’s an example of how Suspense might work in the future with concurrent rendering:

import React, { Suspense } from 'react';

const UserProfile = React.lazy(() => import('./UserProfile'));

function App() {
return (
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile />
</Suspense>
);
}

export default App;

In this example, React would suspend rendering until the UserProfile component (which may include async data fetching) is ready. When Suspense for data fetching becomes widely available, it will further streamline async state management by allowing you to focus on declarative code rather than managing loading and error states manually.

3. Canceling Async Requests to Avoid Race Conditions

When managing asynchronous requests, especially in scenarios where the user triggers multiple requests in a short period, it’s essential to cancel pending requests to avoid race conditions. This prevents situations where old requests resolve after newer ones, leading to outdated data being displayed in the UI.

Most modern APIs, including the Fetch API, support aborting requests using AbortController.

Canceling Requests with AbortController

Here’s an example of using AbortController to cancel an ongoing fetch request if a new request is triggered before the previous one completes:

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);

useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;

async function fetchUserData() {
setLoading(true);
try {
const response = await fetch(`https://api.example.com/user/${userId}`, { signal });
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Fetch error:', error);
}
} finally {
setLoading(false);
}
}

fetchUserData();

return () => {
controller.abort(); // Cancel the request if the component unmounts or userId changes
};
}, [userId]);

if (loading) {
return <p>Loading...</p>;
}

return user ? <div>{user.name}</div> : <p>No user data</p>;
}

In this example, if the userId prop changes before the fetch request completes, the previous request will be aborted, preventing any race conditions or outdated data from being displayed.

4. Integrating Server-Side Rendering (SSR) with Async State

When building apps that rely heavily on asynchronous data fetching, Server-Side Rendering (SSR) can significantly improve performance and SEO by rendering pages on the server and delivering fully rendered HTML to the client. This eliminates the need for the user to wait for JavaScript to load before seeing meaningful content.

Frameworks like Next.js make it easy to handle async state with SSR by providing hooks like getServerSideProps and getStaticProps, which allow you to fetch data on the server before sending the rendered page to the client.

Example of SSR in Next.js

export async function getServerSideProps() {
const res = await fetch('https://api.example.com/user');
const user = await res.json();

return {
props: {
user,
},
};
}

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

export default UserProfile;

In this example, the user data is fetched on the server, ensuring that the page is fully rendered when delivered to the client, which improves load times and ensures that search engines can index the content.

Conclusion: Managing Async State for Performance and User Experience

Handling async state effectively is critical to delivering a seamless user experience in modern frontend applications. By implementing best practices—such as clear loading states, graceful error handling, caching strategies, and using the right state management tools—you can build apps that are responsive, resilient, and easy to maintain.

At PixelFree Studio, we specialize in creating high-performance, user-friendly web applications. Whether you’re looking to optimize async state handling in an existing project or need help building a new app from the ground up, our team of experts can guide you every step of the way. Contact us today to learn more about how we can help you enhance your frontend development process!

Read Next: