DEV Community

Cover image for Custom React Hooks Make Asynchronous Data Fetching Easy (er)
Seb
Seb

Posted on • Originally published at nimblewebdeveloper.com on

Custom React Hooks Make Asynchronous Data Fetching Easy (er)

When you're building a cool app with React, you will often eventually have the need to fetch remote or asynchronous data. Maybe you need to grab some data from an API to display posts, or get search result data for a search query. Whatever your use case, fetching remote data in React can sometimes get a little tricky.

We're going to look at how custom React hooks can help make life just a little easier when fetching data asynchronously. We'll take a look at three ways you might fetch data in your React component.

What do we need to know to fetch data?

If you're loading foreground data (i.e. it's not in the background, and it matters to the user) then we need to know a couple of things. Bare minimum we'd like;

  • the loaded data (if it exists)
  • whether the data is loading or not
  • and whether there was an error loading the data

To manage this, we need 3 different state variables (yes I know you could put them all in one state object): the data, the loading state, and the error, plus the logic to set them all correctly based on particular actions.

For example, on load start, we need to set loading to true, error to null, and fire the request. When the request returns we need to set loading to false, and depending on whether it was successful or not, set the data or the error. Potentially we might want a 'reset' function to reset the state to default or idle.

A simple way of fetching data

Let's quickly recap a method of fetching data in a React component that you've probably seen or used before. The issues with this method become clear pretty quickly.

Consider the code example below (or check out the codepen underneath).

// A sample component to fetch data from an async source  
// Note that the 'fetchFn' isn't specified, but assume it  
// returns a promise  

// this component just shows a list of people,  
// its not necessary, just part of the example  
const DisplayPeople = ({ people }) => {  
  return (  
    <div className="people">  
      {people.map((person, index) => (  
        <div className="person" key={index}>  
          {person.name}  
        </div>  
      ))}  
    </div>  
  );  
};  


// Here's our component that uses async data  
const Component1 = props => {  
  const [data, setData] = useState();  
  const [isLoading, setIsLoading] = useState(false);  
  const [error, setError] = useState(false);  

  const fetchData = async () => {  
    setIsLoading(true);  
    setError(null);  

    try {  
      const resp = await fetchFn(shouldFail);  
      setData(resp);  
      setIsLoading(false);  
    } catch (e) {  
      setError(e);  
      setIsLoading(false);  
    }  
  };  

  return (  
    <div>  
    {/\* If not isLoading, show a button to load the data  
    // otherwise show a loading state \*/ }  
      {!isLoading ? (  
        <div>  
          <button onClick={() => fetchData()}>Load data</button>  
        </div>  
      ) : (  
        "Loading..."  
      )}  

      {/\* if not isLoading and there is an error state,  
      display the error \*/ }  
      {!isLoading && error ? (  
        <div>  
          <p>Oh no something went wrong!</p>  
        </div>  
      ) : null}  
      {/\* If we have data, show it \*/}  
      {data ? <DisplayPeople people={data.results} /> : null}  
      {/\* if there's no data and we're not loading, show a message \*/ }  
      {!data && !isLoading ? <div>No data yet</div> : null}  
    </div>  
  );  
};  

Enter fullscreen mode Exit fullscreen mode

This component loads data from some asynchronous source when the button is clicked.

When the button is clicked, the following actions need to happen;

  1. set the error state to null (in case there was a previous error)
  2. set the loading state to true (so we know it's loading)
  3. fire the data fetching function and wait for a response
  4. set the loading state to false on a response
  5. store the error or data response state

And then in our render function, we have a few messy if s to check (yes I've used ternary operators here, but you could have a separate function with ifs or a switch).

So what's wrong with this?

Nothing is wrong with this. It works fine, it fetches data and shows a response. But see how we need to manage three separate state variables? Imagine you need to make two API calls in your component. Or one call that depends on a another. Suddenly you've got at least 6 state variables (unless you can find a way to reuse them?)

A custom hook to fetch data

We can somewhat address these problems in a slightly better way. We can abstract the logic required to make this work into a custom hook.

How exactly you'd go about this probably depends on your app, and how you want to use it, but I'm going to show you a fairly generic way that can be used to help simplify your component.

First we're going to create a custom hook, then we're going to modify the component to use it. I'm going to show you the code first (in case you're just here for the ol' copy paste) then talk about it.

The custom hook; I like to call him 'useAsyncData'

import { useState, useEffect } from "react";  

//Our custom hook 'useAsyncData'  

// Options:  
// fetchFn (required): the function to execute to get data  
// loadOnMount (opt): load the data on component mount  
// clearDataOnLoad (opt): clear old data on new load regardless of success state  
const useAsyncData = ({  
  loadOnMount = false,  
  clearDataOnLoad = false,  
  fetchFn = null,  
} = {}) => {  
  // Our data fetching state variables  
  const [data, setData] = useState();  
  const [error, setError] = useState();  
  const [isLoading, setIsLoading] = useState(false);  

  // A function to handle all the data fetching logic  
  const loadData = async (event) => {  
    setIsLoading(true);  
    setError();  
    if (clearDataOnLoad === true) setData();  

    try {  
      const resp = await fetchFn(event);  
      setData(resp);  
      setIsLoading(false);  
    } catch (e) {  
      setError(e);  
      setIsLoading(false);  
    }  
  };  

  // 'onMount'  
  // maybe load the data if required  
  useEffect(() => {  
    if (loadOnMount && fetchFn !== null) loadData();  
  }, []);  

  // Return the state and the load function to the component  
  return { data, isLoading, error, loadData };  
};  
export default useAsyncData;  


Enter fullscreen mode Exit fullscreen mode

And the component, refactored to use the custom hook

//Component using custom hook  
const Component2 = (props) => {  
  const { data, isLoading, error, loadData } = useAsyncData({  
    fetchFn: (event) => fetchFn(event),  
  });  

  return (  
    <div>  
      {!isLoading ? (  
        <div>  
          <button onClick={() => loadData()}>Load the data (success)</button>  
          <button onClick={() => loadData(true)}>Load the data (error)</button>  
        </div>  
      ) : (  
        "Loading..."  
      )}  
      {!isLoading && error ? (  
        <div>  
          <p>Oh no something went wrong!</p>  
        </div>  
      ) : null}  
      {data ? <DisplayPeople people={data.results} /> : null}  
      {!data && !isLoading ? <div>No data yet</div> : null}  
    </div>  
  );  
};  

Enter fullscreen mode Exit fullscreen mode

Or if you'd like to see it in action, check the codepen here:

So what's happening here?

We've created a custom hook, which accepts a function (fetchFn) as a parameter (it also accepts some other useful parameters, but they're not essential). This function should actually do the data fetching and return a promise which resolves with the data, or rejects with an error on failure.

We've then put all the state variable stuff, pretty much exactly the same as the first example, inside the hook.

Then we created a function (loadData) which can accept some arbitrary data (which it will pass to the fetcnFn - just in case you need it). loadData then does all the state logic we previously had in our component (setIsLoading, setError etc). loadData also calls fetchFn to actually get the data.

Finally, we removed the fetchData function from our component, and instead of setting up the three state variables, we simply use the hook instead;

const { data, isLoading, error, loadData } = useAsyncData({  
    fetchFn: (event) => fetchFn(event),  
  });  

Enter fullscreen mode Exit fullscreen mode

Does it make our lives easier?

It does a little bit. It's not perfect. It means we don't have to do all the logic for those three state variables every time we need some data. We still have to call the hook for every API call, but it's better. If you've got a slightly complex data fetching scenario you could compose this custom hook into another custom hook. Sky's the limit!

Pro tip: use state machines

null

As our friendly neighbourhood state machine enthusiast (@davidkpiano) would say; "state machines".

I'm not going to go into depth explaining state machines here as it's outside the scope. If you want a bit of background on state machines, try this video with David himself, and Jason Lengstorf, or this article on CSS tricks(React specific).

Essentially, a (finite) state machine state machine has a number of discrete (or specific) states that it can be in. This can significantly simplify our logic. Take our example above. We have three state variables (not to be confused with our machine's states) that combined, essentially make up our application state. Our application can be idle (nothing has happened yet), loading (we're waiting for the data), success (we got some data), or failure (there was an error getting the data).

Using three separate variables, we have to do a bit of if-checking every time we need to know the state of the application (as you can see in the render method with all the ternary operators).

If we used a state machine instead, we'd have one thing to check: the state (e.g. 'idle', 'loading', 'success', 'error').

Another cool thing with state machines is that we can specify which states the machine can transition to from certain states, and what actions should run in between. Essentially it's predictable.

A state machine for async data fetching

I'm going to show you how you can use a state machine for async. data fetching. This is based heavily on the documentation in the xstate/react docs so definitely check that out.

For this example we're using xstate and @xstate/react so you'll need to install those as dependencies. You could write your own state machine implementation and react hook for it, but why reinvent the wheel? And this is a really good wheel.

$ yarn add xstate @xstate/react  

Enter fullscreen mode Exit fullscreen mode

The xstate library provides the state machine implementation, and @xstate/react provides the custom react hook to bind it to react.

Now we need to set up the state machine.

// fetchMachine.js  

import { Machine } from "xstate";  

// The context is where we will store things like  
// the state's data (for our API data) or the error  
const context = {  
  data: undefined  
};  

// This is our state machine  
// here we can define our states  
// along with what each state should do  
// upon receiving a particular action  
export const fetchMachine = Machine({  
  id: "fetch",  
  initial: "idle",  
  context,  
  states: {  
    idle: {  
      on: { FETCH: "loading" }  
    },  
    loading: {  
      entry: ["load"],  
      on: {  
        RESOLVE: {  
          target: "success",  
          actions: (context, event) => {  
            context.data = { ...event.data };  
          }  
        },  
        REJECT: {  
          target: "failure",  
          actions: (context, event) => {  
            context.error = { ...event.error };  
          }  
        }  
      }  
    },  
    success: {  
      on: {  
        RESET: {  
          target: "idle",  
          actions: \_context => {  
            \_context = context;  
          }  
        }  
      }  
    },  
    failure: {  
      on: {  
        RESET: {  
          target: "idle",  
          actions: \_context => {  
            \_context = context;  
          }  
        }  
      }  
    }  
  }  
});  

Enter fullscreen mode Exit fullscreen mode

Our state machine has some context, or data that it can store, and a set of states, along with which states it should transition to upon certain actions.

For example, our initial state is idle. No data yet. From our states declaration, we can see that if it's idle and receives the FETCH command, it should transition to loading.

We've got four states in total (idle, loading, success, failure), and I've added a 'reset' action so we can get rid of our data and go back to idle if we want.

Finally, we need to import the custom hook from @xstate/react in our component

import { useMachine } from "@xstate/react";  

Enter fullscreen mode Exit fullscreen mode

And use the hook in our component. This replaces our previous hook call. The load function is our loadData function and should 'send' a command back to the machine.

const [state, send] = useMachine(fetchMachine, {  
  actions: {  
    load: async (context, event) => {  
      const { shouldFail = false } = event;  
      try {  
        const resp = await fetchFn(shouldFail);  
        send({ type: "RESOLVE", data: resp });  
      } catch (e) {  
        send({ type: "REJECT", error: e });  
      }  
    },  
  },  
});  

Enter fullscreen mode Exit fullscreen mode

Finally we need to modify our render to use the machine state and context.

return (  
  <div>  
    {state.value === `idle` ? (  
      <div>  
        <button onClick={() => send("FETCH")}>Load the data (success)</button>  
        <button onClick={() => send("FETCH", { shouldFail: true })}>  
          Load the data (error)  
        </button>  
      </div>  
    ) : null}  
    {state.value === `loading` ? (  
      <div>  
        <p>Loading...</p>  
      </div>  
    ) : null}  
    {state.value === `success` ? (  
      <DisplayPeople people={state.context.data.results} />  
    ) : null}  
    {state.value === "failure" ? <div>Something went wrong!</div> : null}  
    {state.value !== "idle" && state.name !== "loading" ? (  
      <div>  
        <button onClick={() => send("RESET")}>Reset</button>  
      </div>  
    ) : null}  
  </div>  
);  

Enter fullscreen mode Exit fullscreen mode

And if you assembled it right (ish) it should look something like this (mileage may vary):

import { useMachine } from "@xstate/react";  
import { Machine } from "xstate";  

const context = {  
  data: undefined  
};  

export const fetchMachine = Machine({  
  id: "fetch",  
  initial: "idle",  
  context,  
  states: {  
    idle: {  
      on: { FETCH: "loading" }  
    },  
    loading: {  
      entry: ["load"],  
      on: {  
        RESOLVE: {  
          target: "success",  
          actions: (context, event) => {  
            context.data = { ...event.data };  
          }  
        },  
        REJECT: {  
          target: "failure",  
          actions: (context, event) => {  
            context.error = { ...event.error };  
          }  
        }  
      }  
    },  
    success: {  
      on: {  
        RESET: {  
          target: "idle",  
          actions: \_context => {  
            \_context = context;  
          }  
        }  
      }  
    },  
    failure: {  
      on: {  
        RESET: {  
          target: "idle",  
          actions: \_context => {  
            \_context = context;  
          }  
        }  
      }  
    }  
  }  
});  




const Component3 = () => {  
  const [state, send] = useMachine(fetchMachine, {  
    actions: {  
      load: async (context, event) => {  
        const { shouldFail = false } = event;  
        try {  
          const resp = await fetchFn(shouldFail);  
          send({ type: "RESOLVE", data: resp });  
        } catch (e) {  
          send({ type: "REJECT", error: e });  
        }  
      },  
    },  
  });  

  return (  
    <div>  
      {state.value === `idle` ? (  
        <div>  
          <button onClick={() => send("FETCH")}>Load the data (success)</button>  
          <button onClick={() => send("FETCH", { shouldFail: true })}>  
            Load the data (error)  
          </button>  
        </div>  
      ) : null}  
      {state.value === `loading` ? (  
        <div>  
          <p>Loading...</p>  
        </div>  
      ) : null}  
      {state.value === `success` ? (  
        <DisplayPeople people={state.context.data.results} />  
      ) : null}  
      {state.value === "failure" ? <div>Something went wrong!</div> : null}  
      {state.value !== "idle" && state.name !== "loading" ? (  
        <div>  
          <button onClick={() => send("RESET")}>Reset</button>  
        </div>  
      ) : null}  
    </div>  
  );  
};  



Enter fullscreen mode Exit fullscreen mode

Top comments (0)