Handling multi-page API calls with React Hooks

chowjiaming

Joseph Chow

Posted on December 30, 2020

Handling multi-page API calls with React Hooks

Today we will be walking through an example of how to make continuous API calls to fetch multiple pages of data. Our goal is to build a small web app using React and the HooksAPI that will load dynamically based on user parameters.

We will be fetching data from the free API CheapShark API, which provides data for sales across multiple game vendors. CheapShark returns data on a page by page basis, so multiple API calls will have to be made to fetch results when there is more than one page of results returned.

You can check out the completed demo project here and the completed code here.

App Overview

Our demo app will allow users to take in three parameters for searching through game deals. You can see in the CheapShark API docs which parameters can be taken in. We will be using Axios to fetch the data and the Reach Hooks API to handle user actions.

You can try an example of what the CheapShark API returns in an example call here: https://www.cheapshark.com/api/1.0/deals?storeID=1&upperPrice=15&pageSize=5.

The API returns all the deals it can find with a price under 15 dollars, but without a page number parameter and a maximum page size of 5, it only returns one page of results. We will see how to handle getting all the results through pagination below.

Initial Project Setup

So let's begin with the basic create-react-app boilerplate and install the axios package. For more information, check out the create-react-app docs.

npx create-react-app steam-sales-pagination;
cd steam-sales-pagination;
npm i axios;
npm start;
Enter fullscreen mode Exit fullscreen mode

First lets create a custom hooks file called useFetchGames.js in a helpers directory to handle fetching our data from CheapShark. This custom hook will need to take in to take in user entered search parameters and the page number of results, so we need to declare them as props.

Let's also declare our base URL we will be working with to call the API as a constant variable. We will be using axios to make our API calls and the useEffect and useReducer hooks to handle user actions and fetching data, so let go ahead and import those as well.

// useFetchGames.js

...

import { useReducer, useEffect } from "react";
import axios from "axios";

const BASE_URL =
  "https://cors-anywhere.herokuapp.com/https://www.cheapshark.com/api/1.0/deals?storeID=1&pageSize=5";

export default function useFetchGames(params, page) {
  return <div></div>;
}

...

Enter fullscreen mode Exit fullscreen mode

Creating our Reducer

Back in our useFetchGames hook, lets create our reducer. First we will need to define our actions. Create an action for making the request, getting our data, error messages, and a next page.

// useFetchHooks.js

...

const ACTIONS = {
  MAKE_REQUEST: "make-request",
  GET_DATA: "get-data",
  ERROR: "error",
  NEXT_PAGE: "next-page",
};

...

Enter fullscreen mode Exit fullscreen mode

In our reducer, we will create a switch statement to handle our actions. In our MAKE_REQUEST action we will set loading our loading variable to be true and our games array to be empty whenever a new request with new parameters is made. In our GET_DATA action we will return the state, set the loading state back to false, and populate our games array from our action payload. In the case of our ERROR action, we will do the same except the games array will be set to empty and our error variable will be set to our payload error.

Our final action to set is NEXT_PAGE. We will be dispatching this action after checking for another page of results with our second API call. We will define the payload below when we write our API calls.

// useFetchHooks.js

...

function reducer(state, action) {
  switch (action.type) {
    case ACTIONS.MAKE_REQUEST:
      return { loading: true, games: [] };
    case ACTIONS.GET_DATA:
      return { ...state, loading: false, games: action.payload.games };
    case ACTIONS.ERROR:
      return {
        ...state,
        loading: false,
        error: action.payload.error,
        games: [],
      };
    case ACTIONS.NEXT_PAGE:
      return { ...state, hasNextPage: action.payload.hasNextPage };
    default:
      return state;
  }
}

...

Enter fullscreen mode Exit fullscreen mode

Now that our actions are defined, let us finish writing our hook. First we need to pass the useReducer hook from react to our reducer and our initial state. useReducer will return us our state and dispatch function. We can set our hook to return our state now.

// useFetchHooks.js

...

export default function useFetchGames(params, page) {
  const [state, dispatch] = useReducer(reducer, { games: [], loading: true });
  return state;
}

...

Enter fullscreen mode Exit fullscreen mode

We will be using the useEffect hook from react to dispatch our actions each time our parameters change. Since we will be calling useEffect each time our parameters change, we will need to cancel our request process if the user is entering more parameters as our requests are made. We will set up a cancel token from axios and set useEffect to return when that happens. You can read more about cancel tokens from axios here.

After getting our first batch of data, we will need to make another request with the same parameters with the page number incremented one higher. Whether if there is data returned on a next page, it is here we will dispatch our NEXT_PAGE action to be true or false. Here is how your hook should look:

// useFetchHooks.js

...

export default function useFetchGames(params, page) {
  const [state, dispatch] = useReducer(reducer, { games: [], loading: true });

  useEffect(() => {
    const cancelToken1 = axios.CancelToken.source();
    dispatch({ type: ACTIONS.MAKE_REQUEST });
    axios
      .get(BASE_URL, {
        cancelToken: cancelToken1.token,
        params: { pageNumber: page, ...params },
      })
      .then((res) => {
        dispatch({ type: ACTIONS.GET_DATA, payload: { games: res.data } });
      })
      .catch((e) => {
        if (axios.isCancel(e)) return;
        dispatch({ type: ACTIONS.ERROR, payload: { error: e } });
      });

    const cancelToken2 = axios.CancelToken.source();
    axios
      .get(BASE_URL, {
        cancelToken: cancelToken2.token,
        params: { pageNumber: page + 1, ...params },
      })
      .then((res) => {
        dispatch({
          type: ACTIONS.NEXT_PAGE,
          payload: { hasNextPage: res.data.length !== 0 },
        });
      })
      .catch((e) => {
        if (axios.isCancel(e)) return;
        dispatch({ type: ACTIONS.ERROR, payload: { error: e } });
      });

    return () => {
      cancelToken1.cancel();
      cancelToken2.cancel();
    };
  }, [params, page]);

  return state;
}

...

Enter fullscreen mode Exit fullscreen mode

Testing our Fetch Hook

Let's head back to our main App.js import useFetchGames. We will need to import the useState hook. Set the initial state for our parameters to an empty object and our default page to be 0.

After that, we can pass our parameters and page number to our useFetchGames hook. useFetchGames will be returning our array of games, loading state, potential error messages, and whether another page of data can be fetched from the API. We can log our results as a test. If you run the app now, you can see the default results populated in our console.

// App.js

...

import { useState } from "react";
import useFetchGames from "./helpers/useFetchGames";

function App() {
  const [params, setParams] = useState({});
  const [page, setPage] = useState(0);
  const { games, loading, error, hasNextPage } = useFetchGames(params, page);

  console.log(games, loading, error, hasNextPage);

  return (
    <div>
      <h1>Seach Steam Sales</h1>
      <h5>
        Powered by <a href="https://apidocs.cheapshark.com/">CheapShark API</a>
      </h5>
    </div>
  );
}

export default App;

...

Enter fullscreen mode Exit fullscreen mode

Setting Up Our Search Form

Right now, we nor the user cannot change the parameters to fetch more specific data, so let us build out our UI. First, install the react-bootstrap package so that we can easily template out some user components.


npm i react-bootstrap

Enter fullscreen mode Exit fullscreen mode

Next, let us create a new functional component called SearchForm.js under a new Components directory in our project. Here is an example of some of the parameter searches formatted with react-boostrap elements.

Make sure you give the name element in each search component matching the parameter name found on CheapShark API. I have used title, upperPrice, and lowerPrice as an example.

// SearchForm.js

...

import React from "react";
import { Form, Col } from "react-bootstrap";

export default function SearchForm({ params, onParamChange }) {
  return (
    <Form className="mb-4">
      <Form.Row className="align-items-end">
        <Form.Group as={Col}>
          <Form.Label>Title</Form.Label>
          <Form.Control
            placeholder="eg. Bioshock"
            onChange={onParamChange}
            value={params.title}
            name="title"
            type="text"
          />
        </Form.Group>
        <Form.Group as={Col}>
          <Form.Label>Highest Price</Form.Label>
          <Form.Control
            placeholder="eg. 29.99"
            onChange={onParamChange}
            value={params.upperPrice}
            name="upperPrice"
            type="text"
          />
        </Form.Group>
        <Form.Group as={Col}>
          <Form.Label>Lowest Price</Form.Label>
          <Form.Control
            placeholder="eg. 5.99"
            onChange={onParamChange}
            value={params.lowerPrice}
            name="lowerPrice"
            type="text"
          />
        </Form.Group>
      </Form.Row>
    </Form>
  );
}


...

Enter fullscreen mode Exit fullscreen mode

Let's head back to our App.js and create a handler function for our parameter changes. In SearchForm.js we set the names of our parameters to match the parameters found in our API, so now we can set our parameters in an array. The beauty of this is that we can expand the app and add more search parameters in a modular format easily. All you would need to do is add another element to SearchForm.js with a matching name parameter to our API.

Also, we will set the page result to send to the API back to 0 when the search parameters change. Next pass both the parameter and handler function to our search form component. Now we will be able to test adding parameters to our search and see them logged in the console.

// App.js

...

  const handleParamChange = (e) => {
    const param = e.target.name;
    const value = e.target.value;
    setPage(0);
    setParams((prevParams) => {
      return { ...prevParams, [param]: value };
    });
  };

...

<SearchForm params={params} onParamChange={handleParamChange} />

...

Enter fullscreen mode Exit fullscreen mode

Displaying Our Results

Now that we can change our search parameters and effectively fetch data from our API, let us create some demo UI elements to display our findings. Create another functional component Game.js that takes in the game objects from the API as a prop. Check out the CheapShark API docs to see what game metadata you have to work with.

Here is an example displaying the game title, sale prices, release dates, and a link to the game on the Steam storefront:

// Game.js

...

import React, { useState } from "react";
import { Card, Button, Collapse } from "react-bootstrap";

export default function Game({ game }) {
  const [open, setOpen] = useState(false);

  return (
    <Card className="mb-3">
      <Card.Body>
        <div className="d-flex justify-content-between">
          <div>
            <Card.Title>
              {game.title} -{" "}
              <span
                className="text-muted font-weight-light"
                style={{ textDecoration: "line-through" }}
              >
                ${game.normalPrice}
              </span>
              <span>{" - "}</span>
              <span className="font-weight-light">${game.salePrice}</span>
            </Card.Title>
            <Card.Subtitle className="text-muted mb-2">
              Release Date:{" "}
              {new Date(game.releaseDate * 1000).toLocaleDateString()}
            </Card.Subtitle>
          </div>
          <img
            className="d-none d-md-block"
            height="50"
            alt={game.title}
            src={game.thumb}
          />
        </div>
        <Card.Text>
          <Button
            onClick={() => setOpen((prevOpen) => !prevOpen)}
            variant="primary"
          >
            {open ? "Hide Details" : "View Details"}
          </Button>
        </Card.Text>
        <Collapse in={open}>
          <div className="mt-4">
            <div>Metacritic Score: {game.metacriticScore}</div>
            <Button
              variant="success"
              href={`https://store.steampowered.com/app/${game.steamAppID}/`}
            >
              Check it out
            </Button>
          </div>
        </Collapse>
      </Card.Body>
    </Card>
  );
}

...

Enter fullscreen mode Exit fullscreen mode

Now we can import our Game component into our App.js. We will import the Container and Spinner component from react-bootstrap so that we can contain our Game component and show a spinner while our app is fetching the data. We can also add in an if statement to display our errors from the API if they occur.

// App.js
...

import { Container, Spinner } from "react-bootstrap";
import Game from "./components/Game";

...

  {loading && <Spinner animation="border" variant="primary" />}
  {error && <h1>{error.message}</h1>}
  {games.map((game) => {
    return <Game key={game.steamAppID} game={game} />;
  })}

...

Enter fullscreen mode Exit fullscreen mode

Pagination

Lastly, let us create pagination to let our user browse through multiple pages of search results. Create a Pagination.js file under the Components directory. This functional component will take in page, setPage, and hasNextPage as props.

Using the pagination component from react-bootstrap, we can create logic based on the page prop to only display the back button if the user has navigated past the first page and only display the next page button if the hasNextPage prop is true. Also, we can use logic based on the page prop to set the ellipsis component to display only if the user has navigated past the second page.

We will a need a function to pass to our onClick elements to adjust the page prop up or down, based on which element the user wants to click to navigate. The function will take in the appropriate increment or decrement to use the setPage prop. When the setPage prop is called, our useEffect hook in useFetchGame.js will dispatch the action to make the next two API calls.

// Pagination.js

import React from "react";
import { Pagination } from "react-bootstrap";

export default function GamesPagination({ page, setPage, hasNextPage }) {
  const adjustPage = (amount) => {
    setPage((prevPage) => prevPage + amount);
  };

  return (
    <Pagination>
      {page !== 0 && <Pagination.Prev onClick={() => adjustPage(-1)} />}
      {page !== 0 && (
        <Pagination.Item onClick={() => setPage(0)}>1</Pagination.Item>
      )}
      {page > 1 && <Pagination.Ellipsis />}
      {page > 1 && (
        <Pagination.Item onClick={() => adjustPage(-1)}>{page}</Pagination.Item>
      )}
      <Pagination.Item active>{page + 1}</Pagination.Item>
      {hasNextPage && (
        <Pagination.Item onClick={() => adjustPage(1)}>
          {page + 2}
        </Pagination.Item>
      )}
      {hasNextPage && <Pagination.Next onClick={() => adjustPage(1)} />}
    </Pagination>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we can import and add our pagination component to our App.js and pass it the page, setPage, and hasNextPage props. I have placed mine above and below our Game components so the user can navigate from the top or bottom.

// App.js

...

import Pagination from "./components/Pagination";

...

  <SearchForm params={params} onParamChange={handleParamChange} />
  <Pagination page={page} setPage={setPage} hasNextPage={hasNextPage} />
  {loading && <Spinner animation="border" variant="primary" />}
  {error && <h1>{handleError(error)}</h1>}
  {games.map((game) => {
    return <Game key={game.steamAppID} game={game} />;
  })}
  <Pagination page={page} setPage={setPage} hasNextPage={hasNextPage} />

...

Enter fullscreen mode Exit fullscreen mode

Congratulations!!

Now, you have a simple app to browse game sales on steam. If you want to take a look at the complete code, or create your own fork, check out my repo here.

πŸ’– πŸ’ͺ πŸ™… 🚩
chowjiaming
Joseph Chow

Posted on December 30, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related