Skip to content

Przemysław Konieczniak - Dev Notes

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.

  1. 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 using fetch.

    1import React, { useState, useEffect } from 'react'
    2
    3export const Users = () => {
    4 const [loading, setLoading] = useState(true)
    5 const [error, setError] = useState('')
    6 const [users, setUsers] = useState([])
    7
    8 useEffect(() => {
    9 setLoading(true)
    10
    11 fetch('https://jsonplaceholder.typicode.com/users')
    12 .then(async response => {
    13 if (!response.ok) {
    14 setLoading(false)
    15 setError('Failed to fetch users')
    16 return
    17 }
    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 }, [])
    28
    29 if (loading) return <p>Loading...</p>
    30 if (error) return <p>{error}</p>
    31
    32 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 use useReducer hook.

  2. 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'
    2
    3const initialState = {
    4 loading: false,
    5 error: '',
    6 users: []
    7}
    8
    9const reducer = (state, action) => {
    10 if (action.type === 'LOADING') {
    11 return {
    12 ...state,
    13 error: '',
    14 loading: true
    15 }
    16 }
    17 if (action.type === 'ERROR') {
    18 return {
    19 ...state,
    20 loading: false,
    21 error: action.error
    22 }
    23 }
    24 if (action.type === 'SUCCESS') {
    25 return {
    26 ...state,
    27 loading: false,
    28 error: '',
    29 users: action.users
    30 }
    31 }
    32 return state
    33}
    34
    35export const Users = () => {
    36 const [state, dispatch] = useReducer(reducer, initialState)
    37
    38 useEffect(() => {
    39 dispatch({ type: 'LOADING' })
    40
    41 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 }, [])
    49
    50 if (state.loading) return <p>Loading...</p>
    51 if (state.error) return <p>{state.error}</p>
    52
    53 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.

  3. 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.js
    1import { getToken } from './auth'
    2import { getErrorMessage } from './errors'
    3
    4export 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 }
    16
    17 if (authorization) {
    18 config.headers.Authorization = `Bearer ${getToken()}`
    19 }
    20
    21 if (!['get', 'head'].includes(method.toLowerCase())) {
    22 config.body = JSON.stringify(payload)
    23 }
    24
    25 let response
    26
    27 try {
    28 response = await fetch(path, config)
    29
    30 if (!response.ok) {
    31 return [getErrorMessage(response.status, path), null]
    32 }
    33
    34 const data = await response.json()
    35 return [null, data]
    36
    37 } catch (err) {
    38 return [getErrorMessage(response.status, path), null]
    39 }
    40}
    Users.jsx
    1import React, { useReducer, useEffect } from 'react'
    2import { usersReducer, initialState } from './usersReducer'
    3import { request } from './services'
    4
    5export const Users = () => {
    6 const [state, dispatch] = useReducer(usersReducer, initialState)
    7
    8 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 }, [])
    20
    21 if (state.loading) return <p>Loading...</p>
    22 if (state.error) return <p>{state.error}</p>
    23
    24 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.

  4. 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.js
    1import { request } from './request'
    2const API_URL = 'https://jsonplaceholder.typicode.com/users/'
    3
    4export const getUsers = async () => {
    5 const [error, users] = await request({
    6 path: API_URL,
    7 method: 'get',
    8 mode: 'cors'
    9 })
    10
    11 if (!users) return [error, users]
    12 return [error, users.map(transformUser)]
    13}
    14
    15export const getUser = async (id) => {
    16 const [error, user] = await request({
    17 path: `${API_URL}${id}`,
    18 method: 'get',
    19 mode: 'cors'
    20 })
    21
    22 if (!user) return [error, user]
    23 return [error, transformUser(user)]
    24}
    25
    26const 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.jsx
    1import { getUsers } from './services'
    2
    3export const Users = () => {
    4 const [state, dispatch] = useReducer(reducer, initialState)
    5
    6 useEffect(() => {
    7 dispatch({ type: 'LOADING' })
    8
    9 getUsers()
    10 .then(async ([error, users]) => {
    11 if (error) return dispatch({ type: 'ERROR', error })
    12 dispatch({ type: 'SUCCESS', users })
    13 })
    14 }, [])
    15
    16 if (state.loading) return <p>Loading...</p>
    17 if (state.error) return <p>{state.error}</p>
    18
    19 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.

  1. 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 or useAsync. 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 the useAsync hook.

    useAsync
    1import { useReducer, useCallback, useEffect } from 'react'
    2
    3export const useAsync = (asyncFunction, immediate = true) => {
    4 const [state, dispatch] = useReducer(reducer, initialState)
    5
    6 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]);
    11
    12 useEffect(() => {
    13 if (immediate) execute();
    14 }, [execute, immediate]);
    15
    16 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.jsx
    1import React, { useReducer, useEffect } from 'react'
    2import { getUsers } from './services/userService'
    3import { useAsync } from './hooks/useAsync'
    4
    5export const Users = () => {
    6 const { loading, error, data } = useAsync(getUsers, true)
    7
    8 if (loading) return <p>Loading...</p>
    9 if (error) return <p>{error}</p>
    10
    11 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.jsx
    1import React, { useReducer, useEffect } from 'react'
    2import { useAsync } from 'react-async-hook';
    3import { getUsers } from './services/userService'
    4
    5export const Users = () => {
    6 const asyncUsers = useAsync(getUsers, [])
    7
    8 if (asyncUsers.loading) return <p>Loading...</p>
    9 if (asyncUsers.error) return <p>{error}</p>
    10
    11 const users = asyncUsers.result || []
    12
    13 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.

Resources

© 2020 by Przemysław Konieczniak - Dev Notes. All rights reserved.