I got the opportunity to implement some asynchronous data flows the other day at work, and I'd love to share my approach with y'all.
Thought Process
Whenever I work with loading and displaying asynchronous data, I prefer separating the data loading work and the data displaying work into two components. For me, this separation of concerns helps me to focus on what a clean, easy to follow logic tree.
Setting up our Loader
Here's what we want our loading component to handle:
- When the component mounts, we should trigger our api call to get our data.
- When this api call triggers, we should set some sort of loading state.
- When the api call is finished, we should set our data as state and indicate our loading has completed.
- We should pass this data down to some other component.
Based on that list, we need two pieces of state -- loading and data. We'll also need to figure out how to hook into our component's mounting. Let's start by setting up our state with the useState hook.
import React, { useState } from 'React'
import Breakfast from './Breakfast' // I utilize breakfast foods as my foo/bar/biz/baz
const DataLoader = () => {
const [ isLoading, setIsLoading ] = useState(false)
const [ data, setData ] = useState([])
return isLoading ? <div>Loading</div> : <Breakfast data={data} />
}
Alright, state is set up! Now we need to make our API call. I'll split this into a new section to make things a little easier to follow.
useEffect
useEffect is how we handle for mounts and updates. This function lets us capture side effects in our function components for use. The tl;dr of the documentation can be found here:
useEffect(callback, dependencyArray)
useEffect can be triggered in two ways: whenever a component mounts, and whenever the value of something in the dependencyArray changes. If you pass an empty array as the second argument, it will ensure useEffect only runs when your component mounts.
We'll be using an asynchronous function within useEffect. Of note - we cannot make our callback function asynchronous, because useEffect must either return a cleanup function or nothing. You'll see in a moment I use the async/await approach for Promise declaration. Implicitly, an async function returns a Promise, so without there being a point in time you could resolve what is now a promise-ified useEffect, it'll all blow up! But, using an async function within useEffect is totally fine.
- import React, { useState } from 'React'
+ import React, { useState, useEffect } from 'React'
import Breakfast from './Breakfast'
const DataLoader = () => {
const [ isLoading, setIsLoading ] = useState(false)
const [ data, setData ] = useState([])
+ useEffect(() => {
+ async function fetchData() {
+ setIsLoading(true)
+ const fetcher = await window.fetch(/some/endpoint)
+ const response = await fetcher.json()
+ setData(response)
+ setIsLoading(false)
+ }
+ fetchData()
}, [])
return isLoading ? <div>Loading</div> : <Breakfast data={data} />
}
Here's how the above function works:
- With an empty dependency array, this useEffect will only run on mount.
- When the component mounts, run fetchData.
- Trigger our loading state. Utilize the Fetch API (We made it happen!!!) to resolve a promise that gets us a response.
- Resolve that promise using the
.json
function to parse the response. - Set our data state to this response, and set our loading state to false.
At each point of the state changes, we'll have a re-render with the appropriate UI.
That's it for our loader! The component receiving our data is pretty standard as far as React components go, so I won't worry about that part of the example.
Improvements
Error Handling
There's some more we can do with our useEffect setup. Let's talk about error handling first.
Async/Await
lends itself well to try/catch/finally blocks, so let's give that a go. Let's extract the inner part of our useEffect and add try/catch/finally to it.
async function fetchData() {
setIsLoading(true)
+ try {
const fetcher = await window.fetch(/some/endpoint)
const response = await fetcher.json()
setData(response)
+ } catch (error) {
+ // Do something with error
+ } finally {
+ setIsLoading(false)
+ }
}
fetchData()
The try
portion will attempt to make our API call. If any error occurs, we will fall into our catch statement. After both of these complete, regardless of the result, we hit our finally block and clear out our loading state.
Cleanup
It's a good idea to handle for a case where the component unmounts so that we don't continue setting state. useEffect
supports cleanup functions which run when a component unmounts. Let's add that functionality in.
useEffect(() => {
+ let didCancel = false
async function fetchData() {
+ !didCancel && setIsLoading(true)
try {
const fetcher = await window.fetch(/some/endpoint)
const response = await fetcher.json()
+ !didCancel && setData(response)
} catch (error) {
// Do something with error
} finally {
+ !didCancel && setIsLoading(false)
}
}
fetchData()
+ return () => { didCancel = true }
}, [])
The returned function we added will run when the component unmounts. This will set didCancel to true, and ensure that all state is only set if didCancel
is false.
Final Words
There's a lot to unpack in this article. However, I wanted to get this out of my head an on to paper. I know other folks have written more in-depth pieces on this topic, but hopefully this encapsulates the challenging parts of leveraging useEffect with async. Please feel free to leave a comment below with any qs!
Top comments (10)
Good article, use this implementation myself! Depending on your use case, you can expand this implementation:
useState
definitions. This makes it possible to store more besides the loading state and the result. It also removes the need for 'finally'. With the reducer, the entire state is updated at once, causing one rerender only, while this implementation will trigger 2 because of the double update of states;const isMounted = React.useRef(true)
to determine if the component is mounted of not (change the value in the callback of a useEffect with an empty dependency array!). With this, you can create a function of the actual fetching that updates the state. This function can be called within the useEffect, but can also be returned in your hook, besides to your state.Example:
Nice post! That's a nice way to handle cancellations too (since fetch doesn't handle cancellations normally). The other way I've seen it done with with an AbortController, which is kind of a weird interface though.
thanks!
Thank you! I'm not familiar with an AbortController, so can't comment on it.
Its not really a cancellation, as the fetch will still finish, but this implementation ensures that nothing will happen if the component is not mounted anymore.
Right, yes (which is why I was pointing out the AbortController as well) - but yes, good to point out that it doesn't actually cancel the request :)
Change a little:
I think try catch doesn't catch async error...
Thanks for this post, helped me solve my problem!
From sync useState to async useState in just a few lines of code with Hookstate: hookstate.js.org/docs/asynchronous...
This was so helpful, thank you so much!!!!