Use Async
Like useEffect, but async for ease of use:
import { useAsyncEffect } from "use-async";
// A React hook, so follow usual React Hook rules:
useAsyncEffect(async () => {
const info = await someAsyncOp();
setState(info);
}, [id]);
The effect receives a signal
that can be used with fetch()
, axios, etc. to cancel ongoing promises:
import { useAsyncEffect } from "use-async";
useAsyncEffect(async (signal) => {
const res = await axios.get("/users", { signal });
setState(res.data);
}, []);
This library has two named exports (feel free to propose more!):
-
useAsyncEffect
: handle async effects without race conditions -
useAsyncData
: handle data fetching operations and dependencies
Getting Started
First install the library in your React (16.8+) project:
npm install use-async
Then import either of the async functions:
import { useAsyncEffect } from "use-async";
Finally, use the hook within your component to do data fetching or other async operations:
import { useAsyncEffect } from "use-async";
export default function UserProfile({ id }) {
const [profile, setProfile] = useState(null);
useAsyncEffect(
async (signal) => {
const res = await axios.get("/users/" + id, { signal });
setProfile(res.data);
},
[id]
);
if (!profile) return <Spinner />;
return (
<div>
<h1>{profile.name}</h1>...
</div>
);
}
API
This library has two named exports:
-
useAsyncEffect
: handle async effects without race conditions -
useAsyncData
: handle data fetching operations and dependencies
Some shared points on both functions:
- The signature of both is first an async function, and second the dependencies array.
- The async function receives as arguments first the signal, and then the spread of the dependencies.
- The signal will be aborted either when the component itself unmounts, or when the dependencies for the hook change. AbortErrors are automatically catched so you don't need to worry about those.
-
useAsyncData
is a wrapper ofuseAsyncEffect
for convenience, to make it easier for fetching data asynchronously to use in the current component. - This library solves two major problems with the traditional
useEffect()
: async functions and race conditions. See this article by Max Rozen about one of the main problems this library solves.
The return of the hooks is different, as well as the expected return from the async callbacks. Please read the documentation below for details.
useAsyncEffect()
This is a similar hook to useEffect()
, but explicitly designed to work asynchronously and to make it easy to handle race conditions:
import { useAsyncEffect } from "use-async";
// Easily handle API calls
const [profile, setProfile] = useState(null);
useAsyncEffect(
async (signal) => {
const data = await getUserProfile(id);
if (signal.aborted) return; // <= Avoid race conditions on the network!
setProfile(data);
},
[id]
);
Note: the above can be simplified even further with useAsyncData() below, but we think it's a very common usage so wanted to give a familiar example to the reader.
The arguments passed to the async function inside useAsyncEffect() are:
-
signal
: an AbortSignal that will be aborted if the component is unmounted or the function becomes stale (when the dependencies change). If the dependencies are an empty array, then it will only indicate when the component is unmounted. -
dep1
: the first dependency from the array of dependencies. -
dep2
: the second dependency from the array of dependencies. - etc.
The signal
is a standard AbortSignal instance, which both fetch()
and axios()
accept out of the box. This means you can cancel ongoing requests that have become stale/unwanted:
// Aborts the request if it becomes invalid while ongoing
const [profile, setProfile] = useState(null);
useAsyncEffect(
async (signal) => {
const res = await axios.get(`/users/${id}`, { signal });
setProfile(res.data);
},
[id]
);
// Aborts the request if it becomes invalid while ongoing
const [profile, setProfile] = useState(null);
useAsyncEffect(
async (signal) => {
const res = await fetch(`/api/users/${id}`, { signal });
const data = await res.json();
setProfile(data);
},
[id]
);
It is normally to cancel any ongoing request if you know it's stale. It's normally not done for how hard it used to be compared to the light benefit of avoiding extra requests, but as you can see above with use-async it becomes easier than ever to abort stale requests!
You can add a cleanup function in two different ways: if the return value is a function, or adding an event listener to signal
. The former is the easiest and most straightforward when you have a single async operation, but the latter might simplify your code if you have a complex series of async operations:
// Simple example: adding a single side effect
useAsyncEffect(async signal => {
const res1 = await op1();
if (signal.aborted) return;
const id = setTimeout(() => {...}, 1000);
return () => {
clearTimeout(id);
};
}, [id]);
// Complex example: adding multiple side effects and cleanups
useAsyncEffect(async signal => {
const res1 = await op1();
if (signal.aborted) return;
const id1 = setTimeout(() => {
...
}, 1000);
signal.addEventListener("abort", () => clearTimeout(id1));
const res2 = await op2();
if (signal.aborted) return;
const id2 = setTimeout(() => {
...
}, 2000);
signal.addEventListener("abort", () => clearTimeout(id2));
}, [id]);
useAsyncData()
This is a helper for those cases when you are fetching data in the async function and setting it to a local variable in the current component. It includes a state machine to make it even easier:
import { useAsyncData } from 'use-async';
const myAsyncOperation = async (signal, id) => {...};
export default function MyAsyncComponent({ id }) {
const [data, status] = useAsyncData(myAsyncOperation, [id]);
if (status === "LOADING") return <Spinner />;
if (status === "ERROR") return <div>{data.message}</div>;
// Whatever the data is and you want to display
return <div>{data.name}</div>;
}
It simplifies the fetching of data and the loading around it. The state machine can be completely ignored if you want a quick and easy usage, you just need to check that the data has the proper structure:
export default function MyAsyncComponent({ id }) {
const [data] = useAsyncData(myAsyncOperation, [id]);
// Whatever the data is and you want to display
return (
<ul>{Array.isArray(data) ? data.map((item) => <li>{item}</li>) : null}</ul>
);
}
The arguments passed to the async function inside useAsyncData() are:
-
signal
: an AbortSignal that will be aborted if the component is unmounted or the function becomes stale (when the dependencies change). If the dependencies are an empty array, then it will only indicate when the component is unmounted. -
dep1
: the first dependency from the array of dependencies. -
dep2
: the second dependency from the array of dependencies. - etc.
So, the dependencies will be passed as arguments to this callback. This makes it a easier to extract the callback as a different function if wanted, specially since the data will be set when returned from the function:
import { useAsyncData } from "use-async";
// Extract it into a single function accepting the signal and the deps as args.
const getUserProfile = async (signal, id) => {
const res = await fetch(`/users/${id}`, { signal });
const data = await res.json();
return data;
};
const MyComponent = ({ id }) => {
// Provide the callback and deps; which are injected as args after "signal"
const [profile] = useAsyncData(getUserProfile, [id]);
// ...
};
The default value should be done by using the destructuring default value:
const [data = "myDefaultValue", status] = useAsyncData(...);
The "LOADING"
status might be shown even when data
is defined. This happens when the previous data is stale, and it gives you enough flexibility to decide what to do while loading the new data. You can hide the stale data, dim it out, overlay a loading indicator over it, etc.
Some examples on how to deal with the stale data while loading new data:
const [data, status] = useAsyncData(...);
// Example 1 - replace the whole page for a spinner while loading new data
if (status === "LOADING") return <Spinner />;
return <ItemList data={data} />;
// Example 2 - replace only a part of the page for a spinner
return (
<div>
{status === "LOADING" ? <Spinner /> : <ItemList data={data} />}
</div>
);
// Example 3 - overlay a spinner on top of the stale data
return (
<Page overlaySpinner={status === "LOADING"}>
<ItemList data={data} />
</Page>
);
// Example 4 - show a small spinner on top, similar to pulling down on Twitter
return (
<div>
{status === "LOADING" && <SmallSpinner />}
<ItemList data={data} />
</div>
);
// etc
Note: assuming that if there's no "data", ItemList graciously shows a message
Examples
Simple profile fetch
As we saw before, this is a simple profile fetch that also avoids race conditions:
// Easily handle API calls
const [profile, setProfile] = useState(null);
useAsyncEffect(
async (signal) => {
const res = await axios.get(`/users/${id}`);
if (signal.aborted) return; // <= Avoid race conditions on the network!
setProfile(res.data);
},
[id]
);
Since Axios (and fetch()
) accept the signal
as an option, the above can also be converted to:
const [profile, setProfile] = useState(null);
useAsyncEffect(
async (signal) => {
const res = await axios.get(`/users/${id}`, { signal });
setProfile(res.data);
},
[id]
);
We also export useAsyncData
, which makes the above even easier:
const [profile, status] = useAsyncData(
async (signal) => {
const res = await axios.get(`/users/${id}`);
return res.data;
},
[id]
);
Finally, the simplest we can do is if we either make axios return simply the data instead of the response (with an interceptor) or we put that as a separated function:
// Outside our component
const getProfile = async (signal, id) => {
const res = await axios.get(`/users/${id}`);
return res.data;
};
export default function UserProfile({ id }) {
const [profile, status] = useAsyncData(getProfile, [id]);
return (...);
};
If we want to do the same with the native useEffect
, it becomes a lot more cumbersome since now we need to track the status manually:
const [profile, setProfile] = useState(null);
useEffect(() => {
let isActive = true;
axios.get(`/users/${id}`).then((res) => {
if (!isActive) return;
setProfile(res.data);
});
return () => {
isActive = false;
};
}, [id]);
For this code, that has the issue that it doesn't even check if the current page is still mounted before killing it:
// How you might be doing it now
const [state, setState] = useState(null);
useEffect(() => {
axios.get("/pages/" + id).then((res) => {
setState(res.data);
});
}, [id]);
Easily handle async API calls:
// New way of doing it
const [state, setState] = useState(null);
useAsyncEffect(
async (signal) => {
const res = await axios.get("/pages/" + id);
if (signal.aborted) return;
setState(res.data);
},
[id]
);
@n1ru4l/use-async-effect
Compare to This library for use-async-effect gets some bits right (we should support generators at some point!), but IMHO it still gives you too many shotguns to shot your foot with. Let's compare their clean example given here with our code:
// After 🤩
import useAsyncEffect from "@n1ru4l/use-async-effect";
const MyComponent = ({ filter }) => {
const [data, setData] = useState(null);
useAsyncEffect(
function* (onCancel, c) {
const controller = new AbortController();
onCancel(() => controller.abort());
const data = yield* c(
fetch("/data?filter=" + filter, {
signal: controller.signal,
}).then((res) => res.json())
);
setData(data);
},
[filter]
);
return data ? <RenderData data={data} /> : null;
};
Our solution of the same problem is this:
// ✅ Name easier to remember
import { useAsyncEffect } from "use-async";
const MyComponent = ({ filter }) => {
const [data, setData] = useState(null);
// ✅ Signal is already provided by the library
useAsyncEffect(
async (signal) => {
// ✅ More readable code, so easier to follow workflow
// ✅ await is simpler than a generator+yield
// ✅ signal will cancel if the component is unmounted or the deps change
const res = await fetch("/data?filter=" + filter, { signal });
const data = await res.json();
setData(data);
},
[filter]
);
return data ? <RenderData data={data} /> : null;
};
The implementation with our library (use-async
) is half of the lines of code (10 vs 18) while keeping your code legible and straightforward.
We've looked at this and other existing libraries, and found that we could improve meaningful upon them. That's why we decided to launch use-async
on 2021 instead of using one of the existing ones.
Thanks
Special thanks to:
- Max Rozen's great article on using AbortSignal with useEffect. I had a rough idea on how to proceed, and that article cemented it!
-
use-async-effect
(to which I contributed theisMounted()
check) for being what I've been using for a while. It's what I've learned by using it that allowed me to createuse-async
.