Fetching data in React
— react
Fetching and sending data are trivial tasks. Things get a little bit complicated when we'd like to show a loader when the request is processing or allow users to retry requests which failed. We can deal with this topic in many ways. Generally speaking, everything depends on how we'd like to manage the component's state.
The second thing that we need to put into consideration is a way how we'll invoke the request.
We can call an API directly from the component using fetch
or axios
, but we can also provide an abstraction or a service that will contain the necessary logic.
Let's take a look at different approaches to fetching the data.
useState
The simplest approach will be using a
useState
hook for holding a state for different scenarios. We'll also call an API directly from the component usingfetch
.1import React, { useState, useEffect } from 'react'23export const Users = () => {4 const [loading, setLoading] = useState(true)5 const [error, setError] = useState('')6 const [users, setUsers] = useState([])78 useEffect(() => {9 setLoading(true)1011 fetch('https://jsonplaceholder.typicode.com/users')12 .then(async response => {13 if (!response.ok) {14 setLoading(false)15 setError('Failed to fetch users')16 return17 }18 const users = await response.json()19 setLoading(false)20 setError('')21 setUsers(users)22 })23 .catch(error => {24 setLoading(false)25 setError(error.message)26 })27 }, [])2829 if (loading) return <p>Loading...</p>30 if (error) return <p>{error}</p>3132 return (33 <ul>34 {users.map(user=> <li key={user.id}>{user.name}</li>)}35 </ul>36 )37}It's the simplest way to fetching the data and the easiest to implement. Unfortunately, this solution is not scalable and has many disadvantages.
To make a simple request to the endpoint to fetch the data we produced quite a lot of code. We're calling multiple times
useState
one by one to set the fetched data and hide loaders or error messages. To simplify the code and reduce the number of re-renders we can useuseReducer
hook.useReducer
Instead of keeping separate states for a particular stage of fetching data we can keep the state in one place. With this approach the state will be updated by dispatching an action which will be handled by reducer. This hook requires a little bit more code but in the end, our code will be easier to understand and we'll reduce numbers of re-renders.
1import React, { useReducer, useEffect } from 'react'23const initialState = {4 loading: false,5 error: '',6 users: []7}89const reducer = (state, action) => {10 if (action.type === 'LOADING') {11 return {12 ...state,13 error: '',14 loading: true15 }16 }17 if (action.type === 'ERROR') {18 return {19 ...state,20 loading: false,21 error: action.error22 }23 }24 if (action.type === 'SUCCESS') {25 return {26 ...state,27 loading: false,28 error: '',29 users: action.users30 }31 }32 return state33}3435export const Users = () => {36 const [state, dispatch] = useReducer(reducer, initialState)3738 useEffect(() => {39 dispatch({ type: 'LOADING' })4041 fetch('https://jsonplaceholder.typicode.com/users')42 .then(async response => {43 if (!response.ok) return dispatch({ type: 'ERROR', error: 'Failed to fetch users' })44 const users = await response.json()45 dispatch({ type: 'SUCCESS', users })46 })47 .catch(error => dispatch({ type: 'ERROR', error: error.message }))48 }, [])4950 if (state.loading) return <p>Loading...</p>51 if (state.error) return <p>{state.error}</p>5253 return (54 <ul>55 {state.users.map(user=> <li key={user.id}>{user.name}</li>)}56 </ul>57 )58}In more complex components we can move all things related to the state logic to the separate files, add action creators, and so on. Now, the logic responsible for updating the state is inside a reducer function and we can update the state in a more declarative way, also our code is easier to test.
We improved our code but our solution is still not scalable. What if we'd like to add another component that needs to fetch some data or we'd need to perform data transformation before passing them to the component? In that case, we'd need to copy and paste a lot of code and it will lead in the future to the problems with maintaining the application.
Creating a request utility
It's a common practice to have a wrapper for the fetch function. It allows us to have the flexibility to choosing the library which we'd like to use and make easier migration in the future. Another reason that having a wrapper is a good idea, is that that usually requests need additional configuration like setting proper headers or authorization tokens. Instead of doing this each time manually, we can have this stuff in one place. The same rule can be applied for generic error messages for particular status codes.
request.js1import { getToken } from './auth'2import { getErrorMessage } from './errors'34export const request = async ({5 path,6 method,7 mode = 'same-origin',8 authorization = false,9 payload = {}10}) => {11 const config = {12 method,13 mode,14 headers: { 'Content-Type': 'application/json' },15 }1617 if (authorization) {18 config.headers.Authorization = `Bearer ${getToken()}`19 }2021 if (!['get', 'head'].includes(method.toLowerCase())) {22 config.body = JSON.stringify(payload)23 }2425 let response2627 try {28 response = await fetch(path, config)2930 if (!response.ok) {31 return [getErrorMessage(response.status, path), null]32 }3334 const data = await response.json()35 return [null, data]3637 } catch (err) {38 return [getErrorMessage(response.status, path), null]39 }40}Users.jsx1import React, { useReducer, useEffect } from 'react'2import { usersReducer, initialState } from './usersReducer'3import { request } from './services'45export const Users = () => {6 const [state, dispatch] = useReducer(usersReducer, initialState)78 useEffect(() => {9 dispatch({ type: 'LOADING' })10 request({11 path: 'https://jsonplaceholder.typicode.com/users',12 method: 'get',13 mode: 'cors'14 })15 .then(async ([error, users]) => {16 if (error) return dispatch({ type: 'ERROR', error })17 dispatch({ type: 'SUCCESS', users })18 })19 }, [])2021 if (state.loading) return <p>Loading...</p>22 if (state.error) return <p>{state.error}</p>2324 return (25 <ul>26 {state.users.map(user=> <li key={user.id}>{user.name}</li>)}27 </ul>28 )29}After these changes, we have a common request function that is more specific. We can easily use this util across the whole app and we don't have to configure each request manually. I also moved the stuff related to the reducers to the separate file, to simplify the component.
It looks better but our component it's still strictly related to our API call which makes this component harder to test. Additionally, we need sometimes to apply some data transformation, and putting this stuff inside the component it's not a good idea.
Services
Instead of having API calls directly inside components, we can gather them in standalone modules. Usually, these modules are called services and they grouped by the endpoints that they communicating with.
userService.js1import { request } from './request'2const API_URL = 'https://jsonplaceholder.typicode.com/users/'34export const getUsers = async () => {5 const [error, users] = await request({6 path: API_URL,7 method: 'get',8 mode: 'cors'9 })1011 if (!users) return [error, users]12 return [error, users.map(transformUser)]13}1415export const getUser = async (id) => {16 const [error, user] = await request({17 path: `${API_URL}${id}`,18 method: 'get',19 mode: 'cors'20 })2122 if (!user) return [error, user]23 return [error, transformUser(user)]24}2526const transformUser = (({ id, name, email, address }) => ({27 id,28 name,29 email,30 address: `31 ${address.zipcode}, ${address.city}32 ${address.street} ${address.suite}33 `34}))Users.jsx1import { getUsers } from './services'23export const Users = () => {4 const [state, dispatch] = useReducer(reducer, initialState)56 useEffect(() => {7 dispatch({ type: 'LOADING' })89 getUsers()10 .then(async ([error, users]) => {11 if (error) return dispatch({ type: 'ERROR', error })12 dispatch({ type: 'SUCCESS', users })13 })14 }, [])1516 if (state.loading) return <p>Loading...</p>17 if (state.error) return <p>{state.error}</p>1819 return (20 <ul>21 {state.users.map(user =>22 <li key={user.id}>23 <span>name: {user.name}</span><br/>24 <span>email: {user.email}</span><br/>25 <span>address: {user.address}</span><br/>26 </li>27 )}28 </ul>29 )30}This service can contain all methods that dealing with users endpoint. Logic related to data transformation or validation is placed in one place.
We did quite a lot of improvements and we have a nice separation of concerns. But we can still add some improvements.
Our code related to communicating with API is scalable and we don't have a lot of repetition. But when we'd like to create another component that fetches some data we still have to create another reducer and manually dispatch actions like Loading, Success or Error.
useAsync hook
All we need is a way to share some logic between the components. In the past before the hooks era, this problem was being solved by using the HOC pattern. Because this example is based on Functional Components and hooks I'll not cover this topic.
Coming back to our custom hook, we'd like to move all the states related to fetching data to one place. It will be a black box that will provide for us information about the current state of processing our async function.
For this purpose, actually we could use two hooks.
useFetch
oruseAsync
. The first one -useFetch
is more specific. It's focused only on fetching data. It's ideal when we don't have a services layer but we prefer to work directly with fetch or our fetch wrapper. In other case, we want to a more flexible solution that will work with all async functions not just with API calls, and because we decided to use the services layer we need theuseAsync
hook.useAsync1import { useReducer, useCallback, useEffect } from 'react'23export const useAsync = (asyncFunction, immediate = true) => {4 const [state, dispatch] = useReducer(reducer, initialState)56 const execute = useCallback(async () => {7 dispatch({ type: 'LOADING' })8 const [error, data] = await asyncFunction()9 error ? dispatch({ type: 'ERROR', error }) : dispatch({ type: 'SUCCESS', data })10 }, [asyncFunction]);1112 useEffect(() => {13 if (immediate) execute();14 }, [execute, immediate]);1516 return { execute, ...state };17};Our hook is responsible for executing async functions. This function can be called immediately when the component is rendered for the first time or we can call this function manually. Because we wrapped this function in
useCallback
it will be fired only when a reference to async function will change.Users.jsx1import React, { useReducer, useEffect } from 'react'2import { getUsers } from './services/userService'3import { useAsync } from './hooks/useAsync'45export const Users = () => {6 const { loading, error, data } = useAsync(getUsers, true)78 if (loading) return <p>Loading...</p>9 if (error) return <p>{error}</p>1011 return (12 <ul>13 {data.map(user =>14 <li key={user.id}>15 <span>name: {user.name}</span><br/>16 <span>email: {user.email}</span><br/>17 <span>address: {user.address}</span><br/>18 </li>19 )}20 </ul>21 )22}We almost finished. Maybe you noticed that the code of useAsync is quite simple and a lot of use cases are not covered. We cannot invoke our functions with arguments and probably we'd like to fetch some data when some arguments of functions changed. When we look into some libraries that provide this hook than we'll notice that the code is more complex than our hook. Instead of reinventing the wheel, we can use a library that will cover all of these use cases.
One of the good library that is focused only on this hook is react-async-hook. This library is really light and doesn't contain any dependencies also the documentation is really clear. After the installation and usage of this library, our component didn't change a lot.
Users.jsx1import React, { useReducer, useEffect } from 'react'2import { useAsync } from 'react-async-hook';3import { getUsers } from './services/userService'45export const Users = () => {6 const asyncUsers = useAsync(getUsers, [])78 if (asyncUsers.loading) return <p>Loading...</p>9 if (asyncUsers.error) return <p>{error}</p>1011 const users = asyncUsers.result || []1213 return (14 <ul>15 {users.map(user =>16 <li key={user.id}>17 <span>name: {user.name}</span><br/>18 <span>email: {user.email}</span><br/>19 <span>address: {user.address}</span><br/>20 </li>21 )}22 </ul>23 )24}Summary
We incrementally improved our code related to the fetching data. We started from the basic approach and moving to the more sophisticated solutions. We learned also how we can structure our functions related to API calls and how to share logic between the components using custom hooks.