DEV Community

Cover image for Asynchronous Flows...with React Hooks!
Jonathan Silvestri
Jonathan Silvestri

Posted on • Updated on

Asynchronous Flows...with React Hooks!

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!

Discussion (10)

Collapse
kevtiq profile image
Kevin Pennekamp

Good article, use this implementation myself! Depending on your use case, you can expand this implementation:

  • use a reducer instead of multiple 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;
  • when you are in the need of a 'refetch' (really doing the exact same call) you can use a 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:

const useFetch = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const isMounted = useRef(false);

  useEffect(() => {
    isMounted.current = true;
    return () => { isMounted.current = false; }
  }, []);

  useEffect(() => {
    fetch();
  }, [someDependency]); 

  async function fetch (vars) => {
    if (!isMounted.current) return; 
    dispatch({ type: 'INIT' });

    try {
      const result = await fetch(...);
      if (isMounted.current) dispatch({ type: 'SUCCESS', payload: result });
    } catch (e) {
      if (isMounted.current) dispatch({ type: 'ERROR', payload: e });
    }
  } 


  return [state, fetch];
}
Collapse
chrisachard profile image
Chris Achard

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!

Collapse
silvestricodes profile image
Jonathan Silvestri Author

Thank you! I'm not familiar with an AbortController, so can't comment on it.

Collapse
kevtiq profile image
Kevin Pennekamp

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.

Collapse
chrisachard profile image
Chris Achard

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 :)

Collapse
ormadont profile image
Evgeniy Pavlovich Demyanov • Edited on

Change a little:

import { useState, useEffect } from 'react'
const DataLoader = asyncFunc => {
    const [data, setData] = useState({})
    useEffect(() => {
        async function fetchData() {
            const response = await asyncFunc()
            setData(response)
        }
        fetchData()
    }, [])
    return data
}
export default DataLoader

Enter fullscreen mode Exit fullscreen mode
Collapse
shkyung profile image
janggun umma

I think try catch doesn't catch async error...

Collapse
indavidjool profile image
indavidjool

Thanks for this post, helped me solve my problem!

Collapse
avkonst profile image
Andrey

From sync useState to async useState in just a few lines of code with Hookstate: hookstate.js.org/docs/asynchronous...

Collapse
seanmc9 profile image
Sean McCaffery

This was so helpful, thank you so much!!!!