DEV Community

loading...
Cover image for Data fetching React Hook

Data fetching React Hook

mbellagamba profile image Mirco Bellagamba Originally published at mircobellagamba.com ใƒป6 min read

Making HTTP requests is a common task for most Single Page Applications. Due to the asynchronous nature of network requests, we need to manage the state of the request during its lifecycle: the start, the loading phase and finally the processing of the response or errors handling, if any occurred.

The problem

Today it is more and more frequent to start a new React.js web app without using any external state management library, such as Redux, but just relying on the React State and the React Context. Since React.js 16.8 was released, this trend increased even more because the introduction of the Hooks simplified the Context APIs, making them more appealing from a developer point of view.
In this kind of web app a React component making a network request could look like the following.

import * as React from "react"
import { topicsURL } from "./api"

function TopicsList() {
  const [topics, setTopics] = React.useState([])
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState(null)
  React.useEffect(() => {
    setLoading(true)
    fetch(topicsURL)
      .then(response => {
        if (!response.ok) {
          throw new Error("Request failed")
        }
        return response.json()
      })
      .then(data => setTopics(data))
      .catch(e => setError(e))
      .finally(() => setLoading(false))
  }, [])

  if (error) {
    return <div>An error has occurred: {error.message}</div>
  }
  if (loading) {
    return <div>Loading...</div>
  }
  return (
    <ul>
      {topics.map(topic => (
        <li key={topic.id}>
          <a href={topic.url}>{topic.title}</a>;
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

The TopicsList component is fairly good but most of its code deals with the management of the network request, hiding its real purpose: showing a list of topics. It smells like a separation of concerns issue.
Moreover, the same code will be duplicated in many other components, modifying only the request URL. Each component will declare three state variables, make the request inside an effect, manage the loading state, conditionally render the component only when the request is successful.
Finally, the request status depends on the value of three variables (topics, loading, error). It's easy to mess up things just checking these variables with the wrong order. To better understand the problem, check the article Stop using isLoading booleans.

The useFetch Hook

We could solve the issues previously described defining a custom hook that manages network requests. Our goals are:

  1. Avoid rewriting the logic to manage requests.
  2. Separate the request management code from the rendering.
  3. Handle the request status in an atomic way.
import * as React from "react"

const reducer = (state, action) => {
  switch (action.type) {
    case "loading":
      return {
        status: "loading",
      }
    case "success":
      return {
        status: "success",
        data: action.data,
      }
    case "error":
      return {
        status: "error",
        error: action.error,
      }
    default:
      return state
  }
}

export function useFetch(url) {
  const [state, dispatch] = React.useReducer(reducer, { status: "idle" })
  React.useEffect(() => {
    let subscribed = true
    dispatch({ type: "loading" })
    fetch(url)
      .then(response => {
        if (!response.ok) {
          throw new Error("Request failed")
        }
        return response.json()
      })
      .then(data => {
        if (subscribed) {
          dispatch({ type: "success", data })
        }
      })
      .catch(error => {
        if (subscribed) {
          dispatch({ type: "error", error })
        }
      })
    return () => {
      subscribed = false
    }
  }, [url])
  return state
}
Enter fullscreen mode Exit fullscreen mode

The useFetch hook is a useful abstraction and it can be easily shared among the components of the app. The request status depends on the single status variable, instead of three. The subscribed variable prevents a component update on an unmounted component, when the unmount event happens before the request completion.
No one is happy to see this warning in browser console.

Warning: Canโ€™t call setState (or forceUpdate) 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 in the componentWillUnmount method.

Using the hook

With the useFetch hook the TopicsList component becomes like this.

import { useFetch, topicsURL } from "./api"

function TopicsList() {
  const res = useFetch(topicsURL)
  return (
    <>
      {res.status === "loading" && <div>Loading...</div>}
      {res.status === "error" && (
        <div>An error has occurred: {res.error.message}</div>
      )}
      {status === "success" && (
        <ul>
          {res.data.map(topic => (
            <li key={topic.id}>
              <a href={topic.url}>{topic.title}</a>
            </li>
          ))}
        </ul>
      )}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

The code is more readable because it sharply defines the component purpose. Now the rendering logic is separated from request management and there's no mixed level of abstractions.

Bonus #1: TypeScript version

For type safety lovers (here I am โœ‹), here's the TypeScript version.

import * as React from "react"

export type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }

export type RequestAction<T> =
  | { type: "start" }
  | { type: "completed"; data: T }
  | { type: "failed"; error: Error }

export function useFetch<T>(route: string): RequestState<T> {
  const [state, dispatch] = React.useReducer<
    React.Reducer<RequestState<T>, RequestAction<T>>
  >(reducer, { status: "idle" })
  React.useEffect(() => {
    let subscribed = true
    if (route) {
      dispatch({ type: "start" })
      fetch(route)
        .then(response => {
          if (!response.ok) {
            throw new Error("Request failed")
          }
          return response.json()
        })
        .then(data => {
          if (subscribed) {
            dispatch({ type: "completed", data })
          }
        })
        .catch(error => {
          if (subscribed) {
            dispatch({ type: "failed", error })
          }
        })
    }
    return () => {
      subscribed = false
    }
  }, [route])
  return state
}

export function reducer<T>(
  state: RequestState<T>,
  action: RequestAction<T>
): RequestState<T> {
  switch (action.type) {
    case "start":
      return {
        status: "loading",
      }
    case "completed":
      return {
        status: "success",
        data: action.data,
      }
    case "failed":
      return {
        status: "error",
        error: action.error,
      }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

Then it could be useful to define an helper function with proper typing for each request, instead of using the hook directly in components. The topics request would be like this.

function useTopics(): RequestState<Topic[]> {
  return useFetch(topicsURL)
}
Enter fullscreen mode Exit fullscreen mode

The Union type enforces us to check the status of the response before accessing any other properties. Writing res.data is allowed only if the language is sure that the status is "success" in the same scope. So, thanks to TypeScript, we can forget about mistakes like Uncaught TypeError: Cannot read property 'map' of undefined.

ย Bonus #2: Testing tips

The useFetch hook could help us to simplify unit tests. In fact, we can spy on the hook and return a proper test double. Testing the component becomes easier because the hook spy hides the asynchronous behavior of fetch requests, serving directly the response.
The stub let us reason about the component behavior and the test expectation without worrying about async execution.
Assuming to use Jest and Testing Library, a unit test for the topics list component could be like the following.

import * as React from "react"
import { render, screen } from "@testing-library/react"
import TopicsList from "../TopicsList"
import * as api from "../api"

const testData = Array.from(Array(5).keys(), index => ({
  id: index,
  title: `Topic ${index}`,
  url: `https://example.com/topics/${index}`,
}))

test("Show a list of topic items", () => {
  jest.spyOn(api, "useTopics").mockReturnValue({
    status: "success",
    data: testData,
  })
  render(<TopicsList />)
  expect(screen.getAllByRole("listitem")).toHaveLength(testData.length)
})
Enter fullscreen mode Exit fullscreen mode

Even if there are alternatives to mocking fetch requests in tests Stop mocking fetch, this approach can be useful in complex situations when setting up an asynchronous unit test would be tricky.

Going further

The useFetch hook is a handy utility to retrieve data from the server and to manage network requests. It is simple enough yet quite powerful. Anyway, it is not perfect for every use case and I would leave you with some considerations.

  • The custom hook can be easily modified to work with any asynchronous task, i.e. with every function returning a Promise. For instance, its signature can be like the following.
function useAsync<T>(task: Promise<T> | () => Promise<T>): AsyncState<T>`
Enter fullscreen mode Exit fullscreen mode
  • It is easy to replace the native fetch with Axios. There's only need to remove the code that checks if the response is successful and parse the JSON response body because Axios does it internally.
  • If the API endpoint require some headers, like Authorization, you can define a custom client function that enhance fetch requests with required headers and replace fetch with this client.
  • In complex web apps, making a lot of network requests, requiring advanced features like caching, it will probably be better to use React Query, a powerful React data synchronization library.

Connect

Do you find it useful? Do you have any question about it? Feel free to comment or contact me. You can reach me out on Twitter @mircobellaG.

Discussion (14)

pic
Editor guide
Collapse
devhammed profile image
Hammed Oyedele

Nice article but you should look into AbortController for that subscribed part as this would also cancel the actual request if it is ongoing:

import * as React from "react"

export type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }

export type RequestAction<T> =
  | { type: "start" }
  | { type: "completed"; data: T }
  | { type: "failed"; error: Error }

export function useFetch<T>(route: string): RequestState<T> {
  const [state, dispatch] = React.useReducer<
    React.Reducer<RequestState<T>, RequestAction<T>>
  >(reducer, { status: "idle" })
  React.useEffect(() => {
    const abortController = new AbortController()

    if (route) {
      dispatch({ type: "start" })
      fetch(route, { signal: abortController.signal })
        .then(response => {
          if (!response.ok) {
            throw new Error("Request failed")
          }
          return response.json()
        })
        .then(data => {
            dispatch({ type: "completed", data })
        })
        .catch(error => {
          if (error.name !== 'AbortError') {
            dispatch({ type: "failed", error })
          }
        })
    }
    return () => {
      abortController.abort()
    }
  }, [route])
  return state
}

export function reducer<T>(
  state: RequestState<T>,
  action: RequestAction<T>
): RequestState<T> {
  switch (action.type) {
    case "start":
      return {
        status: "loading",
      }
    case "completed":
      return {
        status: "success",
        data: action.data,
      }
    case "failed":
      return {
        status: "error",
        error: action.error,
      }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
mbellagamba profile image
Mirco Bellagamba Author

Thanks for the suggestion! This AbortController solution makes more explicit the cancel operation. Anyways, I don't use it very often because of the low browser support. Maybe, I should start using it shipping a polyfill for older browsers ๐Ÿค”.

Collapse
devhammed profile image
Hammed Oyedele

If you are worried about browser support, axios offers a similar API on top of XHR or go the polyfill way as you have said.

Collapse
sebalreves profile image
sebalreves

how can i use this hook for submitting a form? it gives me an error because it's calling inside the submitHandler function. Or it's better just to write an asynchronous function for this?

Collapse
mbellagamba profile image
Mirco Bellagamba Author

This hook cannot work for mutation requests, like form submissions, because it starts the fetch request as soon as the component is mounted. If you need to handle form submissions you should write a "classic" async handler.

Collapse
eecolor profile image
EECOLOR

Great tutorial!

Making fetch useful in more and more React scenario's will however become more complex over time. This video about react-query shows what I mean: youtube.com/watch?v=seU46c6Jz7E

I'm not saying you should use react-query and I encourage people to write things themselves before they grab a library. But it's nice to have an option in the back of your head when you reach the point where you are saying to yourself: "ouch, this is becoming too complex".

Collapse
mbellagamba profile image
Mirco Bellagamba Author

Thanks for the feedback! I know react-query and I agree with you that it's more suitable in complex web apps. That's what I wrote just before the "Connect" title ๐Ÿ˜Š, maybe you missed it.

In complex web apps, making a lot of network requests, requiring advanced features like caching, it will probably be better to use React Query, a powerful React data synchronization library.

Collapse
eecolor profile image
EECOLOR

Ahh yeah, I must have skipped over it.

Thread Thread
mbellagamba profile image
Mirco Bellagamba Author

Maybe I should have talked more about React Query because it is a very powerful library, as you said. Thanks to your comment, this tip has gotten the space it deserves ๐Ÿ˜ƒ.

Collapse
pankajpatel profile image
Pankaj Patel

Great article on useFetch

Thanks for adding TS and Testing around the hook

Collapse
bazenteklehaymanot profile image
bazen-teklehaymanot

nice article, the useFetch hook reusable.one thing to think about is you can also implement HOC say FetchHOC that renders the child component based in the state of the async action(calling API). that way your code will be even more reusable

Collapse
mbellagamba profile image
Mirco Bellagamba Author

Thanks for the suggestion! Generally speaking, I prefer the hooks approach because it's more explicit, but it's just a matter a preference. With an HOC we also need to handle props collision, if the component needs to make 2 request. Anyway, I'll try to refactor the hook in a HOC to see if it's even more practical.

Collapse
mrezah1 profile image
MrezaH

perfect๐Ÿ‘ , thaknks๐Ÿ™

Collapse
kuetabby profile image
Ivan

this is awesome thank you!