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:
- Function changed from ArticleList to useFetch
- URL parameter added to function to allow reusability
- State variables changed from
articles
todata
to allow reusability - 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 usewaitFor
under the hood), but the second is simpler and the error message you get will be better.Advice: use
– Excerpt from “Common mistakes with React Testing Library” by Kent C. Doddsfind*
any time you want to query for something that may not be available right away.
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:
- useFakeTimers is invoke, time is simulated
- useFetch hook is invoked with help from renderHook (API Doc)
- cleanup is invoked with no delay (because useFetch is async)
- Cleanup method is invoked in useFetch
- fetchController.abort() is called and Fetch request is stopped
- useFetch is unmounted
- 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