Handling async operations has been a major concern for developers in the React ecosystem.
There’s been a bunch of different approaches to handling async operations, including Redux-Saga, but in this article we’ll be focusing on what I think is currently the best approach: using react-async
.
We’ll also make comparisons between the react-async
library and other existing methods of handling asynchronous side effects in React.
React Async is a promise-based tool that lets you handle promises and fetch data declaratively.
It makes it easy to handle asynchronous UI states without assumptions about the shape of your data or the type of request.
React Async consists of a React component and several Hooks. You can use it with fetch, Axios, GraphQL, and other data fetching libraries.
React Async relies on using a declarative syntax, JSX, and native promises to resolve data closer to where you need them in your code (for example, on the component level), unlike other systems like Redux where resolving data happens at a higher level in your application using things like actions and reducers.
To use React Async like in the example below, we will import useAsync
from react-async
Then we can create our asynchronous function, which receives a signal as a parameter. The signal is our AbortController API, which provides a way for us to cancel the fetch call we make if there’s ever a need to do so.
In our component, we call useAsync
and pass on our asynchronous function.
Calling useAsync
returns an object that we can de-structure into three important values: data, error, and isPending.
These values tell us about the state of our asynchronous function — whether it’s still pending, errored out, or successful.
We can use each of these values to render an appropriate UI for the user:
import { useAsync } from "react-async" // You can use async/await or any function that returns a Promise const asyncFn = async ({ signal }) => { const res = await fetch(`/api/users`, { signal }) if (!res.ok) throw new Error(res.statusText) return res.json() } const MyComponent = () => { const { data, error, isPending } = useAsync({ promiseFn: asyncFn }) if (isPending) return "Loading..." if (error) return `Something went wrong: ${error.message}` if (data) <ul> {data.users.map(user => <li>{user.name}</li>)} </ul> ) return null
There are a few documented ways of using React-Async:
useFetch
I’ll be briefly touching the first three methods just to give you an idea of these implementations, but feel free to reference the official usage guide to get an in-depth view of each of the methods.
React-Async provides a Hook called useAsync
. Within your component, you can call this Hook like so:
import { useAsync } from "react-async"; const MyComponent = () => { const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, playerId: 1 }) //... };
useFetch
With useFetch
, you are creating an asynchronous fetch function which can be run at a later time in your component:
import { useFetch } from "react-async" const MyComponent = () => { const headers = { Accept: "application/json" } const { data, error, isPending, run } = useFetch("/api/example", { headers }, options) // You can call "handleClick" later function handleClick() { run() } <button onClick={handleClick}>Load</button> }
Here’s where React Async really shines with JSX:
import Async from "react-async" const MyComponent = () => ( <Async promiseFn={load}> { ({ data, error, isPending }) => { if (isPending) return "Loading..." if (error) return `Something went wrong: ${error.message}` if (data) return (<div> { JSON.stringify(data, null, 2) }</div>) return null } } </Async> )
You have to pass a function to the Async
component as a child.
As you can see, this function will evaluate different node values based on the state of the asynchronous function we provided as props to Async
.
useEffect
useEffect
in combination with Async/Await isn’t quite as convenient as React Async, especially when you start thinking of race conditions, handling clean-ups, and cancelling pending async operations.
React Async handles all these thin gs for you in a very efficient manner.
Let’s take a look at a typical example of handling race conditions with useEffect
and Async/Await:
const [usersList, updateUsersList] = useState(); useEffect(() => { const runEffect = async () => { const data = await fetchUsersList(filter); updateUsersList(data); }; runEffect(); }, [updateUsersList, filter]);
In the case above, if for any reason we have to call the useEffect
twice and the second call to fetchUsersList
resolves before the first, we’d have an outdated “updated” list.
You can fix this by adding a way to prevent the updateUsersList
call from happening when you deem it necessary, however, such methods might not scale well with multiple await
expressions.
On the other hand, you don’t have to wory about cancelling unresolved requests or handling proper race conditions when you use React Async because React already handles that for you:
import { useAsync } from "react-async" // You can use async/await or any function that returns a Promise const fetchUsersList = async ({ signal }) => { const res = await fetch(`/api/users`, { signal }) if (!res.ok) throw new Error(res.statusText) return res.json() } const filteredUsers = (users) => { // Filter users ... } const MyComponent = () => { const { data, error, isPending } = useAsync({ promiseFn: fetchUsersList}) if (isPending) return "Loading..." if (error) return `Something went wrong: ${error.message}` if (data) <ul> { filteredUsers(data.users).map(user => <li>{user.name}</li>) } </ul> ) return null
In the above code snippet, any time we call fetchUsersList
, we would be re-rendering the MyComponent
component, which means we’ll always have the state we expect.
Also, React Async does a clean up internally for us and cancels unresolved promises using the AbortController
API (i.e the signal
variable being passed to fetchUsersList
function,) so we don’t have to worry about race conditions and cancelling unresolved promises that we no longer need.
If your application is really basic and adding a 14kb library to handle async operations doesn’t make sense, then you could settle for a slightly more advanced implementation of useEffect
.
In my opinion, React Async is already pretty light-weight and has lots of great benefits in addition to being well-tested.
So unless the gains you get from saving 14kb is crucial, you might want to use React Async.
Redux-Saga
is a library that aims to make application side effects (i.e. asynchronous things like data fetching and impure things like accessing the browser cache) easier to manage, more efficient to execute, easy to test, and better at handling failures: redux-saga.js.org.
Redux-Saga requires a lot more steps to get started than React Async.
That’s because it’s a Redux middleware, which means you have to set up Redux for it.
The idea of Redux is to have a centralized state for all or major parts of your application. That way, you can update your state through dispatching actions
. For instance:
const Counter = ({ value }) => <div> <button onClick={() => store.dispatch({type: 'INCREMENT_ASYNC'})}> Increment after 1 second </button> <hr /> <div> Clicked: {value} times </div> </div>
Redux-Saga helps you make network calls or perform other asynchronous side effects by relying on “ES6 Generators”:
function* incrementAsync() { yield delay(1000) yield put({ type: 'INCREMENT' }) }
As you can see, where you do your side effects is far removed from your components. You trigger updates by dispatching actions within your component. Then, the updated state comes in through your props.
Pretty standard stuff, but very different and less intuitive than what React Async gives you.
You don’t have to understand a fairly complex structure with constructs like reducers and actions — you utilize what you already know and use within your components – JSX, Promises, and Hooks.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]
One Reply to "How to handle async side effects in 2019"
Peter, very interesting article! I will need to check out the technologies you mentioned that I’m not already familiar with.
Given your great knowledge on this subject matter, may I ask for your advice? I’ve successfully built several React modules with functional components which make all async calls from within a useEffect. I’ve done it this way because this is what the official documentation & all articles I’ve read say should be done. A new colleague feels that there’s nothing wrong with making all async GET calls (ex. axios.get) from within, say, a button event handler. I don’t have a solid technical explanation to give him as to why this approach could lead to problems. Might you?