When dealing with asynchronous operations in React, managing loading states can become repetitive. Let’s explore how to handle this common pattern, starting with a basic approach and then improving it with custom hooks
Prerequisites
Before diving into this article, you should have:
- Basic understanding of React and its hooks (useState, useEffect, useCallback)
- Familiarity with TypeScript syntax and basic types
- Knowledge of asynchronous JavaScript (Promises, async/await)
- Understanding of HTTP requests and the fetch API
Traditional Approach
Here’s how we typically handle loading states with useState: In the first example, we will leave out the error handling part for simplicity.
import { useState, useEffect } from "react";
type User = {
name: string;
};
function UserProfile() {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [userData, setUserData] = useState<User | null>(null);
const fetchUserData = async () => {
try {
setIsLoading(true);
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1"
);
const data: User = await response.json();
setUserData(data);
} finally {
setIsLoading(false);
}
};
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<div>{userData ? userData.name : "No data available"}</div>
)}
<button onClick={fetchUserData}>Fetch Data</button>
</div>
);
}
This approach works but has several drawbacks:
- We need to manage loading state manually for each async operation
- The loading state logic is repeated across async operations
- It’s possible to forget to reset the loading state after finishing the async operation
Solution
import { useState } from "react";
function useLoading<T>(asyncFunction: () => Promise<T>) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const execute = async () => {
setIsLoading(true);
try {
return await asyncFunction();
} finally {
setIsLoading(false);
}
};
return { isLoading, execute };
}
Explanation
useLoading
is a React custom hook that keeps track of the isLoading
state and executes the async function.
Applying the solution
Now we can refactor our component to use this custom hook:
import { useState } from "react";
import { useLoading } from "../hooks/useLoading";
type User = {
name: string;
};
function UserProfile() {
const [userData, setUserData] = useState<User | null>(null);
const { isLoading, execute: fetchUserData } = useLoading(async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1"
);
const data: User = await response.json();
setUserData(data);
});
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<div>{userData ? userData.name : "No data available"}</div>
)}
<button onClick={fetchUserData}>Fetch Data</button>
</div>
);
}
Benefits of this approach:
- Simplified Loading State Management
The loading logic is abstracted away, allowing developers to focus on wrapping async operations withuseLoading
while automatically handling theisLoading
state. - Consistent Handling Across Async Operations
Using a single pattern ensures uniform loading state management across different parts of the application. - More Concise and Maintainable Code
By eliminating repetitive state management logic, this approach improves code readability and reduces the risk of forgetting to reset the loading state.
Adding an error handling to useLoading
In the earlier implementation, we focused on simplifying loading state management, but we omitted error handling to keep the code easy to understand. However, in real-world applications, asynchronous operations often fail due to network issues, API errors, or other unexpected problems.
To make our solution more robust, we will enhance the useLoading
hook to track and handle errors properly. This will ensure that errors are caught, displayed to users, and reset correctly.
import { useState } from "react";
import { useLoading } from "../hooks/useLoading";
export function useLoading<T>(asyncFunction: () => Promise<T>) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const execute = async () => {
setIsLoading(true);
setError(null);
try {
return await asyncFunction();
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
throw err; // Re-throw the error so the caller can still handle it if needed
} finally {
setIsLoading(false);
}
};
return { isLoading, error, execute };
}
Then we can do this in UserProfile
import { useState } from "react";
type User = {
name: string;
};
function UserProfile() {
const [userData, setUserData] = useState<User | null>(null);
const { isLoading, error, execute: fetchUserData } = useLoading(async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1"
);
if (!response.ok) {
throw new Error("Failed to fetch user data");
}
const data: User = await response.json();
setUserData(data);
});
return (
<div>
{isLoading && <div>Loading...</div>}
{error && <div style={{ color: "red" }}>Error: {error.message}</div>}
{!isLoading && !error && (
<div>{userData ? userData.name : "No data available"}</div>
)}
<button onClick={fetchUserData} disabled={isLoading}>
Fetch Data
</button>
</div>
);
}
Problem: Using execute function as dependency
Currently, execute function will be re-render everytime. this will be a problem when we want a function to be a stable dependency of another hook (like useEffect, useMemo, or another useCallback).
For example, we want this hook to execute every time page is load.
normally we would do this, Howerver, this will cause fetchUserData to be called indefinitely as fetchUserData
is re-render everytime the component is render
useEffect(() => {
fetchUserData();
}, [fetchUserData]);
We could do the following to ensure this is load only once on component load, however this might run the risk that fetchUserData
might change
useEffect(() => {
fetchUserData();
}, []);
Solution
import { useCallback, useState } from "react";
export function useLoading<T>(
asyncFunction: () => Promise<T>,
deps: React.DependencyList = []
) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
return await asyncFunction();
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
throw err; // Re-throw the error so the caller can still handle it if needed
} finally {
setIsLoading(false);
}
}, deps);
return { isLoading, error, execute };
}
Explanation:
useCallback
is used to memoize theexecute
function so it will not be re-render everytime the component is renderdeps
is the dependency list that will be used to memoize theexecute
function
Code
You can find a working example of this code in this CodeSandbox demo.
What happens if setState is called after a component has been unmounted?
const execute = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
return await asyncFunction();
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
throw err; // Re-throw the error so the caller can still handle it if needed
} finally {
setIsLoading(false);
}
}, deps);
Since we need to await asyncFunction
to complete before calling setIsLoading(false)
, it’s possible that the component using this hook might be unmounted before setIsLoading(false)
is called.
In React versions prior to 18, you would see this warning:
Warning: Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
However, this warning message can be misleading because there isn’t actually a memory leak in this case. The React team has acknowledged this and removed the warning in React 18. You can read more about this decision in the React Working Group discussion.
Summary
Encapsulating the loading state into a custom react hook is an effective technique for managing asynchronous logic. This approach offers multiple benefits, such as improving code reusability and maintainability.
Research & Sources
- React 18 State Update on Unmounted Components: In React 18, the warning for setting state on unmounted components has been removed. This change was made because the previous warning often led to confusion and unnecessary code complexity. For more details, see the discussion on the React 18 Working Group’s GitHub: Update to remove the “setState on unmounted component” warning
Extra
The main purpose of this article is to demonstrate the usage of useLoading
. However, you might want to further refactor by encapsulating the user fetching logic into a custom hook like this:
import { useState } from "react";
function useFetchUserData() {
const [userData, setUserData] = useState<User | null>(null);
const { isLoading, error, execute: fetchUserData } = useLoading(async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1"
);
if (!response.ok) {
throw new Error("Failed to fetch user data");
}
const data: User = await response.json();
setUserData(data);
});
return { userData, isLoading, error, fetchUserData };
}
function UserProfile() {
const { userData, isLoading, error, fetchUserData } = useFetchUserData();
return (
<div>
{isLoading && <div>Loading...</div>}
{error && <div style={{ color: "red" }}>Error: {error.message}</div>}
{!isLoading && !error && (
<div>{userData ? userData.name : "No data available"}</div>
)}
<button onClick={fetchUserData} disabled={isLoading}>
Fetch Data
</button>
</div>
);
}
About the Author
My name is Golf, I am a frontend engineer at Money Forward.