Fetching Data with async/await in a React Component


Fetching data from a resource, such as a JSON API is a common thing these days, and so is the use of asynchronous (async) functions that avoid locking up applications.

In React components, async functions have some additional caveats to keep in mind that –if left unchecked– can introduce memory leaks or race conditions.

In this article, we’ll be covering a small app that fetches articles from an API and lists them out. We’ll take a look at async functions in a React component, address caveats, and checkout making our function reusable as a custom React hook.

This article will cover:
๐Ÿ’š Async/Await use in a React component
๐Ÿ’š Writing cleaner code with custom hooks
๐Ÿ’š Testing async/await in Jest ๐Ÿ‘ฉโ€๐Ÿ”ฌ

Prerequisites
โœ” Familiar with functional React component basics
โœ” Basic understanding of async/await

1. Asynchronous Fetch Code ๐Ÿ•

(source)

Alright, let’s start out with our async function, fetchArticles, which will call the fetch API. It encapsulates the call to fetch and handles the response, including the scenario an error HTTP status is returned.

After the articles have been retrieved by fetch and converted into an Article object from JSON, they are set state with setArticles().

// types.d.ts
interface IArticle {
    id: number;
    title: string;
    body: string;
}


// fetchPosts.ts
async function fetchArticles() {
    try {
        const post_response = await fetch(
            "https://jsonplaceholder.typicode.com/posts"
        );
    
        if (!post_response.ok) {
            throw new Error(
                `HTTP Response ${post_response.status}: ${post_response.statusText}`
            );
        }
        const data: Array<IArticle> = await post_response.json();
        // Saves to state!
    } catch (error) {
        setError(error);
    }
}

๐Ÿ”Ž Async/Await and TypeScript Definition Refresher

I want to touch on a few things being used here so there’s no confusion.

  • interface IArticle is a TypeScript feature that defines a shape for a given value
  • An async function returns a Promise (example)
  • try/catch is used with await (opposed to .catch() in a chain)

ComponentDidMount Lifecycle Method

So we have the function that will retrieve the articles, but where does it go?

Because a functional component is run whenever it needs to be rerendered, it’s important to avoid operations that take time to complete or are meant to be run once.

This is where React lifecycle methods come into play. With functional components, these “methods” are implemented with useEffect. For this example, the ComponentDidMount “method” can be used for a one-time call to fetchArticles.

๐Ÿ”Ž ComponentDidMount ReTriggering

To clarify, that’s one time per mount. If the user navigates away, causing the component to unmount, and then navigates back, the ComponentDidMount “method” will be triggered a second time.

function MyComponent() {
    const [articles, setArticles] = useState<IArticle | undefined>(undefined);

    useEffect(() => { // Called after component mount
        ...
        async function fetchArticles() {
            ...
            const response = await fetch(url);
            ...
        }
        fetchArticles();
    }, []);

    return <div>Content</div>
}

Async Functions and Unmounted Components ๐Ÿงน

An important thing to keep in mind when working with async functions within components is, at the moment a component is unmounted, its state no longer exists. If we attempt to alter state (that doesn’t exist) React complains about it.

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…

This is a problem for our app because, given the right circumstances, it’s possible the async function could still be running after the component has unmounted! ๐Ÿ˜ฑ

The good news is there’s a solution for that, React uses the return value from useEffect to clean up before the component unmounts.

We will extend our example by setting the state through setArticles after fetching the articles. We’ll return a callback from the useEffect that will be used to cancel fetchArticles() and avoid further code being run against invalid state.

Things are actually a little tricky here. We want to cancel a fetch request if it’s still in flight, but that’s not all. It’s possible the fetch has been completed, but setArticles hasn’t been reached yet. This means, setArticles could still be called after the component is unmounted.

Let’s step through this…

    const [articles, setArticles] = useState<any>(null);
    const [error, setError] = useState<any>(null);

    useEffect(() => {
       const fetchController = new AbortController(); 1๏ธโƒฃ

        async function fetchArticles() {
            ...
            const response = await fetch(
                url,
                { signal: fetchController.signal } 2๏ธโƒฃ
            );

            const data: Array<IArticle> = await response.json();

            if (!fetchController.signal.aborted) { 4๏ธโƒฃ
                setArticles(data);
            }
            ...
        }
        fetchArticles();

        return () => {
            fetchController.abort(); 3๏ธโƒฃ
        };
    }, [url]);

AbortController

Fetch requests can be canceled before they complete using AbortController.

1๏ธโƒฃ Creates an instance of AbortController, used to control Fetch AI
2๏ธโƒฃ Fetch takes a signal as an optional argument, AbortController.signal is passed in
3๏ธโƒฃ If component unmounts, AbortController.abort is called, automatically canceling fetch request if it’s happening
4๏ธโƒฃ aborted property used to check if AbortController.abort was called, this guards against a race condition

2. Keeping Things Tidy With a Fetch Hook ๐Ÿ˜ผ(Cats fetch too)

(source)

We have all the pieces to put together an async fetch in a React component that will retrieve a list of articles and populate the component once the data is received.

import React, { useEffect, useState } from "react";

export default function ArticleList() {
    const [articles, setArticles] = useState<Array<IArticle> | undefined>(
        undefined
    );
    const [error, setError] = useState<any>(null);

    const fetchController = new AbortController();
    const signal = fetchController.signal;

    useEffect(() => {
        async function fetchArticles() {
            try {
                const response = await fetch(
                    "https://jsonplaceholder.typicode.com/posts",
                );

                const data: Array<IArticle> = await response.json();
                setArticles(data);
            } catch (error: any) {
                setError(error.message);
            }
        }
        fetchArticles();

        return () => {
            fetchController.abort();
        };
    }, []);

    return (
        <div>
            This is an example of how to use async/await in Next.js.
            <div>{error}</div>
            <ul aria-label="articles">
                {articles &&
                    articles.map((article: IArticle) => {
                        return <li key={article.id}>{article.title}</li>;
                    })}
            </ul>
        </div>
    );
}

Unfortunately, this component is beginning to get noisy with everything it is handling. Here we can take the “Single Responsibility” principle from S.O.L.I.D and apply it to our component design.

The responsibility of this component is to generate a list of articles, how that data is retrieved can be abstracted out.

By moving our fetch code into a custom React hook, the component doesn’t only become easier to read, but now the fetch code can be reused. Writing tests will also become a bit easier too.

import React from "react";
import useFetch from "../utils/useFetch";

export default function ArticleList() {
    const {
        data: articles,
        error,
    } = useFetch("https://jsonplaceholder.typicode.com/posts");

    return (
        <div>
            This is an example of how to use async/await in Next.js.
            <div>{error}</div>
            <ul aria-label="articles">
                {articles &&
                    articles.map((article: IArticle) => {
                        return <li key={article.id}>{article.title}</li>;
                    })}
            </ul>
        </div>
    );
}

Moving back to the fetch code, custom React hooks are commonly kept in a separate file so that they can be reused. For my example, I created a utils folder at the root level and a file named useFetch.ts to go in it.

The hook function is going to look a lot like the ArticleList component without the JSX.

import { useEffect, useState } from "react";

function useFetch(url: string): any {
    const [data, setData] = useState<any>(null);
    const [error, setError] = useState<any>(null);

    useEffect(() => {
        const fetchController = new AbortController();

        async function fetchArticles() {
            try {
                const response = await fetch(url, {
                    signal: fetchController.signal,
                });

                if (!response.ok) {
                    throw new Error(
                        `HTTP Response ${response.status}: ${response.statusText}`
                    );
                }
                const json_obj = await response.json();

                // Good ol' fashion race condition
                if (!fetchController.signal.aborted) {
                    setData(json_obj);
                }
            } catch (error: any) {
                setError(error.message);
            }
        }
        fetchArticles();

        return () => {
            fetchController.abort();
        };
    }, [url]);

    return { data, error };
}
export default useFetch;

Here’s a list of what changes:

  1. Function changed from ArticleList to useFetch
  2. URL parameter added to function to allow reusability
  3. State variables changed from articles to data to allow reusability
  4. URL (array) passed into second argument to trigger additional fetch on URL change

3. Writing Tests for Async Functions ๐Ÿ‘ฉโ€๐Ÿ”ฌ

Writing tests for your app creates a checklist of expectations for your app. When those tests are automated, it’s easy to tell when something breaks.

Test 1: Article List Test

This first test will “shallow render” our article list and it will confirm all articles load and display a title in an unordered list.

Async functions require some special handling when writing tests, we need to tell the tests to wait for the async functions to complete before continuing with the testing sequence.

import fetch from "jest-fetch-mock";
import posts from "./mock/posts-001.json";

...

it("lists all articles", async () => {
    // Replaces fetch called when ArticleList renders with a mocked version
    fetch.mockResponse(async function () {
        return JSON.stringify(posts);
    });

    // โœ” Shallow render component
    const component = render(<ArticleList />);

    // โŒ Get article list items, but they don't exist yet. BAIL OUT!
    const article_list_items = getArticleListItems(component);

    // โŒ Test won't even run this (When to use .toBe vs .toBeEqual)
    expect(article_list_items.length).toBeEqual(posts.length);
});

My examples will use @testing-library/react, a part of the Testing Library family of packages. @testing-library/react has some tools that will handle the waiting for us, findAllByRole, in particular, will be the tool for this job.

findBy: Returns a Promise which resolves when an element is found which matches the given query. The promise is rejected if no element is found or if more than one element is found after a default timeout of 1000ms.

– Excerpt of findBy definition from Testing Library

There are other functions that are part of @testing-library/dom and @testing-library/react. When I first started exploring this, I was using waitFor, which does the job, but there’s a better option. The author of the library recommends findBy* to be used.

Roles

Before I show the full sample test, I want to talk about roles briefly. They are very useful when pulling nodes from the DOM by text, class, id, or other properties that aren’t ideal.

In our example component, we had markup like this:

<ul aria-label="articles">
    ...
    <li>Article Title</li>
    ...
</ul>

The ul tag has the role of “list”, and by setting the aria-label attribute to “articles”, we can fetch this element using getByRole from @testing-library/dom.

List items (li) nodes have the role of listitem. They can be pulled in a similar fashion, though there’s a little extra done so that we only pull list items belonging to the “articles” list, and because of the async nature of the component (and that more than one is being pulled), findAllByRole is used.

it("lists all articles", async () => {
    fetch.mockResponse(async function () {
        return JSON.stringify(posts);
    });

    // Shallow render
    const { getByRole } = render(<ArticleList/>);

    // Get "articles" ul node
    const list = getByRole("list", {
        name: /articles/i,
    });

    // Get a query function that will pull nodes from the "articles" list node
    const { findAllByRole } = within(list);

    // Query for nodes with a role of "listitem"
    const items: Array<HTMLElement> = await findAllByRole("listitem");

    // Confirm the number of li nodes matches what is expected 
    expect(items.length).toBe(posts.length);

    // Confirm the the li node has the title text
    expect(items[0].textContent).toBe(posts[0].title); // This could be problematic if the title includes HTML
});

Those two bits of code are basically equivalent (find* queries use waitFor under the hood), but the second is simpler and the error message you get will be better.

Advice: use find* any time you want to query for something that may not be available right away.

Excerpt from “Common mistakes with React Testing Library” by Kent C. Dodds

Test 2: Component unmount Mid-Fetch

This next test will assert that a Fetch can be canceled while in progress.

The key thing here is we don’t want the mock response to be returned before the fetch is canceled. This is accomplished using jest.useFakeTimers, and setTimeout.

The useFakeTimers function changes how timed functions work (ex. setTimeout, setInterval); the time is simulated โณ. There isn’t actually a delay in real-time, but processes behave and react as they would in normal time.

    it("can stop fetch during mid-fetch component unmount", async () => {
        const mockData = { sample: "test" };
        let initialFetchRun = false;

         // โณ Time becomes simulated in test
        jest.useFakeTimers();

        fetch.mockResponse(async () => {

            // A promise is returned with a 1 second delay before it resolves
            return new Promise((resolve) => {
                setTimeout(() => {

                    // ๐Ÿ‘Ž If this variable is true, the test fails (it shouldn't)
                    initialFetchRun = true;
                    resolve(JSON.stringify(mockData));

                }, 1000);
            });

        });

        // Special function to test hooks
        const { result, unmount } = renderHook(() =>
            useFetch("https://testdomain.com/pathdoesntexist")
        );

        // ๐Ÿงน This will invoke useFetch's cleanup function
        cleanup();

        expect(initialFetchRun).toBe(false);
    });
import { useEffect, useState } from "react";

function useFetch(url: string): any {
    const [data, setData] = useState<any>(null);
    const [error, setError] = useState<any>(null);

    const fetchController = new AbortController();
    const signal = fetchController.signal;
    useEffect(() => {
        async function fetchArticles() {
            try {
                const response = await fetch(url, { signal });

                if (!response.ok) {
                    throw new Error(
                        `HTTP Response ${response.status}: ${response.statusText}`
                    );
                }

                const json_obj = await response.json();
                setData(json_obj);
            } catch (error: any) {
                setError(error.message);
            }
        }
        fetchArticles();

        return () => {
            fetchController.abort();
        };
    }, [url]);

    return { data, error };
}
export default useFetch;

In case there’s any confusion, here’s a rundown of the sequence in a nutshell:

  1. useFakeTimers is invoke, time is simulated
  2. useFetch hook is invoked with help from renderHook (API Doc)
  3. cleanup is invoked with no delay (because useFetch is async)
  4. Cleanup method is invoked in useFetch
  5. fetchController.abort() is called and Fetch request is stopped
  6. useFetch is unmounted
  7. initialFetchRun is still false, test passes

A more complete set of tests can be found here for the article list component and here for the useFetch component.

Conclusion

Welp, there you have it. Using async functions in components to defer processing until the needed data is retrieved is really helpful. And being able to block within a function using await is also helpful when sequential processing is needed.

Introducing async/await does increase the technical complexity a bit and is a problem for functionality that is time-sensitive. So caution should be exercised so use async/await in blocks of code that either can stop processing without affecting the program or code can run at any time without adverse effects.

I hope this article has helped, questions and feedback are welcome!

Resources

JSONPlacer – Mock JSON data (supports ?_delay=[ms])
Testing Library – JavaScript testing library. Has extensions for the DOM and React
Appearance and Disappearance Guide – Testing elements with asynchronous behavior
React Hooks Testing Library – Library for testing React hooks, includes Async Utilities