DEV Community

Julian Garamendy
Julian Garamendy

Posted on • Updated on • Originally published at juliangaramendy.dev

Loading and Displaying Data with Hooks

In this series, instead of using a state-management library or proposing a one-size-fits-all solution, we start from the bare minimum and we build up our state management as we need it.


  • In this first article we'll describe how we load and display data with hooks.
  • In the second article we'll learn how to change remote data with hooks.
  • In the third article we'll see how to share data between components with React Context without using globals, singletons or resorting to state management libraries like MobX or Redux.
  • In the fourth article we'll see how to share data between components using SWR, which is probably what we should have done from the beginning.

The final code can be found in this GitHub repo. It's TypeScript, but the type annotations are minimal. Also, please note this is not production code. In order to focus on state management, many other aspects have not been considered (e.g. Dependency Inversion, testing or optimisations).

Loading Data with Hooks

Let's say we have a REST API with a list of Commodore 64 games. I mean, why not?

Requirement: We want to load the list and display the games.

my favourite commodore 64 games

1. Basic Fetching

Here's how we retrieve our list of games from the server:

const getGames = () => {
  return fetch('http://localhost:3001/games/').then(response => response.json());
};
Enter fullscreen mode Exit fullscreen mode

We can use this in a React app. Our first iteration looks like this:

App.tsx (rendered by index.tsx) (see repo)

import React from 'react';

const getGames = () => {
  return fetch('http://localhost:3001/games/').then(response => response.json());
};

export const App = () => {
  const [games, setGames] = React.useState([]);

  React.useEffect(() => {
    getGames().then(games => setGames(games));
  }, []);

  return <pre>{JSON.stringify(games, null, 2)}</pre>;
};
Enter fullscreen mode Exit fullscreen mode

On the first render of our App component, the games array will be empty. Then when the promise returned by getGames resolves, the games array contains all our games, and they will be displayed in a very basic manner.

2. Custom React Hook

We can easily extract this to a custom React Hook in a separate file.

useGames.ts (see repo)

import React from 'react';

const getGames = () => {
  return fetch('http://localhost:3001/games/').then(response => response.json());
};

export const useGames = () => {
  const [games, setGames] = React.useState([]);

  React.useEffect(() => {
    getGames().then(games => setGames(games));
  }, []);

  return games;
};
Enter fullscreen mode Exit fullscreen mode

App.tsx (see repo)

import React from 'react';
import { useGames } from './useGames';

export const App = () => {
  const games = useGames();
  return <pre>{JSON.stringify(games, null, 2)}</pre>;
};
Enter fullscreen mode Exit fullscreen mode

3. Handling errors and pending state

Our custom hook is not handling pending and error states. There is no visual feedback while the data is loading from the server, and even worse: there's no error message when it fails. If the server is down, the list of games will remain empty, without errors.

We can fix this. There are libraries for this, the most popular being react-async; but I don't want to add dependencies just yet. Let's see what's the minimum code needed to handle the error and pending states.

useAsyncFunction

We write a custom hook that takes an async function (which returns a Promise) and a default value.

This hook returns a tuple with 3 elements: [value, error, isPending]. It calls the async function once*, and it updates the value when it resolves, unless there's an error, of course.

function useAsyncFunction<T>(asyncFunction: () => Promise<T>, defaultValue: T) {
  const [state, setState] = React.useState({
    value: defaultValue,
    error: null,
    isPending: true
  });

  React.useEffect(() => {
    asyncFunction()
      .then(value => setState({ value, error: null, isPending: false }))
      .catch(error => setState({ ...state, error: error.toString(), isPending: false }));
  }, [asyncFunction]); // *

  const { value, error, isPending } = state;
  return [value, error, isPending];
}
Enter fullscreen mode Exit fullscreen mode

* The useEffect inside our useAsyncFunction will call the async function once and then every time the asyncFunction changes. For more details: Using the State Hook, Using the Effect Hook, Hooks API Reference.

Now in useGames.ts we can simply use this new custom hook, passing the getGames function and the initial value of an empty array as arguments.

...
export const useGames = () => {
  const games = useAsyncFunction(getGames, []); // 🤔 new array on every render?
  return games;
};
Enter fullscreen mode Exit fullscreen mode

There's a small problem, though. We're passing a new empty array every time useGames is called, which is every time our App component renders. This causes our data to be re-fetched on every render, but each fetch results in a new render so it results in an infinite loop.

We can avoid this by storing the initial value in a constant outside the hook:

...
const emptyList = [];

export const useGames = () => {
  const [games] = useAsyncFunction(getGames, emptyList);
  return games;
};
Enter fullscreen mode Exit fullscreen mode

Small TypeScript Interlude

You can skip this section if you're using plain JavaScript.

If you're using strict TypeScript, the above code will not work because of the "noImplicitAny" compiler option. This is because const emptyList = []; is implicitly an array of any.

We can annotate it like const emptyList: any[] = []; and move on. But we're using TypeScript for a reason. That explicit any can (and should) be more specific.

What are the elements of this list? Games! It's a list of games.

const emptyList: Game[] = [];
Enter fullscreen mode Exit fullscreen mode

Of course, now we have to define a Game type. But do not despair! We have our JSON response from the server where each game object looks like this:

{
  "id": 5,
  "title": "Kung-Fu Master",
  "year": 1984,
  "genre": "beat'em up",
  "url": "https://en.wikipedia.org/wiki/Kung-Fu_Master_(video_game)",
  "status": "in-progress",
  "img": "http://localhost:3001/img/kung-fu-master.gif"
}
Enter fullscreen mode Exit fullscreen mode

We can use transform.tools to convert that to a TypeScript interface (or type).

type Game = {
  id: number;
  title: string;
  year: number;
  genre: string;
  url: string;
  status: 'not-started' | 'in-progress' | 'finished';
  img: string;
};
Enter fullscreen mode Exit fullscreen mode
One more thing:

We said useAsyncFunction returned a tuple, but TypeScript's inference (@3.6.2) does not understand that. It inferes the return type as Array<(boolean | Game[] | null)>. We can explicitly annotate the return type of the function to be [T, string | null, boolean] where T is the (generic) type of the value, (string | null) is the type of the error and boolean is isPending.

export function useAsyncFunction<T>(
  asyncFunction: () => Promise<T>,
  defaultValue: T
): [T, string | null, boolean] {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now when we use the function, TypeScript suggests the proper types.

const [games] = useAsyncFunction(getGames, emptyList); // games is of type Game[]
Enter fullscreen mode Exit fullscreen mode

End of TypeScript interlude.

Composing our custom hooks

useAsyncFunction.ts now looks like this: (see repo)

import React from 'react';

export function useAsyncFunction<T>(
  asyncFunction: () => Promise<T>,
  defaultValue: T
): [T, string | null, boolean] {
  const [state, setState] = React.useState({
    value: defaultValue,
    error: null,
    isPending: true
  });

  React.useEffect(() => {
    asyncFunction()
      .then(value => setState({ value, error: null, isPending: false }))
      .catch(error =>
        setState({ value: defaultValue, error: error.toString(), isPending: false })
      );
  }, [asyncFunction, defaultValue]);

  const { value, error, isPending } = state;
  return [value, error, isPending];
}
Enter fullscreen mode Exit fullscreen mode

And we use it in our useGames hook:

useGames.ts (see repo)

import { useAsyncFunction } from './useAsyncFunction';

const getGames = (): Promise<Game[]> => {
  return fetch('http://localhost:3001/games/').then(response => response.json());
};

type Game = {
  id: number;
  title: string;
  year: number;
  genre: string;
  url: string;
  status: 'not-started' | 'in-progress' | 'finished';
  img: string;
};

const emptyList: Game[] = [];

export const useGames = () => {
  const [games] = useAsyncFunction(getGames, emptyList);
  return games;
};
Enter fullscreen mode Exit fullscreen mode

Changing UI to display errors and pending states

Great! But we're stil not handling the error and pending states. We need to change our App component:

import React from 'react';
import { useGames } from './useGames';

export const App = () => {
  const { games, error, isPending } = useGames();

  return (
    <>
      {error && <pre>ERROR! {error}...</pre>}
      {isPending && <pre>LOADING...</pre>}
      <pre>{JSON.stringify(games, null, 2)}</pre>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

And our useGames hook should return an object with three keys: games, error, isPending.

export const useGames = () => {
  const [games, error, isPending] = useAsyncFunction(getGames, emptyList);
  return { games, error, isPending };
};
Enter fullscreen mode Exit fullscreen mode

We're also improving our getGames function to handle HTTP status codes different from 200 as errors:

const getGames = (): Promise<Game[]> => {
  return fetch('http://localhost:3001/games/').then(response => {
    if (response.status !== 200) {
      throw new Error(`${response.status} ${response.statusText}`);
    }
    return response.json();
  });
};
Enter fullscreen mode Exit fullscreen mode

Our code so far looks like this: (see repo).

Conclusion

We've seen how to load data from a REST API using React hooks.

In the next article we'll see how to change remote data using an HTTP PATCH request, and how to update our client-side data when the request is successful.

Resources

Further reading:

Top comments (0)