DEV Community

Cover image for Reusable Loading component in React
Paul Diggle
Paul Diggle

Posted on

Reusable Loading component in React

If you're looking for a way to improve the user experience of your website or application, creating a loading component is a great place to start. In this blog post, I'll explore how to create a reusable loading component in Typescript with React. By leveraging Typescript interfaces, my loading component will be flexible enough to work with any data request in my project.

I'll walk through the process step by step, covering the benefits of using Typescript with React and how it can help me write more robust and maintainable code.

Note: The following steps only cover the main classes used and may not include all classes used in the project. For a fully working example, please refer to the GitHub repository at https://github.com/digglp/reactloadingcomponent

Image description

Image description

Step 1: Create a new project using the following command:

npx create-react-app reactloadingcomponent --template typescript

This will create a new React starter project.

Step 2: Install some dependencies

For this project I will add bootstrap, react-bootstrap and axios.

npm install bootstrap
npm install react-bootstrap
npm install axios

Step 3: Set up the folder structure. For my React projects, I typically create the following folders:

  • src: The top-level folder for all code
  • src/domain: Contains all domain objects
    • src/domain/handlers: Handlers for handling tasks (e.g., getting data)
    • src/domain/helpers: Helper objects (e.g., DatesHelper)
    • src/domain/models: Objects to represent data models (e.g., Weather)
  • src/infrastructure: Contains external dependencies
    • src/infrastructure/repositories: Repositories (e.g., WeatherAPI)
  • src/tests: Contains all test modules, with the same folder structure as the code
    • src/tests/domain
    • src/tests/handlers
    • src/tests/helpers
    • src/tests/models
    • src/tests/infrastructure/repositories
  • src/ui: Contains all React UI code, including components.

Image description

Step 4: Create the data handler interface.

Next, let's create the iHandler interface that defines the template for all our data calls:

export interface IHandler {
  runAsync(request?: any): Promise<any>;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Create the Loader component.

Now, let's create the Loader component. The Loader component takes in several props, including the handler (a handler that the loader will call when loading takes place), handlerData (any object that the handler function requires), onComplete (a function to call after the data successfully completes), onError (a function to call when the data fails), failureMessage (a string that represents what message to show on failure), and canRetry (a boolean value to determine whether to show the retry button).

The Loader component is responsible for loading data, handling errors, and displaying a loading spinner or a retry button. It uses React's useState and useEffect hooks to manage state and run asynchronous code.


    import { useEffect, useState } from "react";
    import { Button, Spinner } from "react-bootstrap";
    import { IHandler } from "../../../../domain/handlers/IHandler";

    type Props = {
      handler: IHandler;
      handlerData?: any;
      onComplete: (data: any) => void;
      onErrored: (error: Error) => void;
      failureMessage?: string;
      canRetry: boolean;
    };

    export const Loader = (props: Props) => {
      const [isLoading, setIsLoading] = useState(true);
      const [isErrored, setIsErrored] = useState(false);

      useEffect(() => {
        const run = async () => {
          if (isLoading) {
            try {
              props.onComplete(await props.handler.runAsync(props.handlerData));
              setIsLoading(false);
            } catch (error: any) {
              props.onErrored(error);
              setIsLoading(false);
              setIsErrored(true);
            }
          }
        };

        run();
      }, [isLoading, props]);

      const retryAsync = async () => {
        setIsLoading(true);
        setIsErrored(false);
      };

      return (
        <>
          {isLoading && (
            <Spinner animation="border" role="status">
              <span className="visually-hidden" data-testid="loading">
                Loading...
              </span>
            </Spinner>
          )}
          {isErrored && (
            <div>
              {props.failureMessage ? props.failureMessage : <span>"Error loading data"</span>}
              {props.canRetry && (
                <Button className="mb-3" variant="secondary" onClick={() => retryAsync()} data-testid="retryButton">
                  Retry
                </Button>
              )}
            </div>
          )}
        </>
      );
    };

Enter fullscreen mode Exit fullscreen mode
Some explanations.
State and useEffect Hook

The initial state isLoading is set to true. When the useEffect hook runs and isLoading is true, the asynchronous call to the network is executed using props.handler.runAsync(props.handlerData). If the call returns without errors, the props.onComplete function is called and the data returned from the handler is passed back. In case of an error, the try catch block handles it by calling the props.onErrored function and setting the isErrored state to true.

Retry

If props.canRetry is true and isErrored state is true, the retry button will be displayed. Clicking the button will trigger the retry function, which sets the isLoading state to true. This will cause the useEffect hook to execute and attempt loading again.

iHandler functionality

The iHandler interface allows any object that implements it to use this loading component, making it reusable across all data calls in your application.

Step 6: Lets use the loader

To demonstrate how the loader component can be used, we'll create a new component and handler to load character data from the Rick And Morty API (https://rickandmortyapi.com/).

For this, we'll need the following bits of code:

Repository Code

  • IRickAndMortyCharacterRepository.ts - a repository interface
  • RickAndMortyCharacterRepository.ts - an implemented repository class
  • IReadRepository.ts - a base read repository interface
  • BaseReadRepository.ts - an implemented class that handles the Axios request/response
  • RepositoryConfigs.ts - a config class that handles environment variables, such as the URL for the API we want to call

Handler Code

  • IHandler.ts - an interface that works with the Loader component
  • CharacterListHandler.ts - a class that implements IHandler.ts

The above structure enables straightforward unit testing by supplying a mock repository to the handler. See the testing section below for more details.

BaseReadRepository.ts
import axios, { AxiosRequestConfig } from "axios";

import { IReadRepository } from "./IReadRepository";

export class BaseReadRepository<T> implements IReadRepository<T> {
  async getDataFromUrlAsync(url: string): Promise<T> {
    const requestConfig = {
      headers: {},
      timeout: 10000,
    } as AxiosRequestConfig;

    const response = await axios.get(url, requestConfig);
    const data = response.data;

    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

The purpose of the BaseReadRepository class is to handle asynchronous read requests. This code uses the axios library to send a GET request to a specified URL and returns the response data. Other repository classes can utilize this implementation to handle read requests in a consistent manner.

RepositoryConfig.ts
export class RepositoryConfigs {
  static configuration = {
    development: {
      characterUrl: "https://rickandmortyapi.com/api/character",
    },
    beta: {
      characterUrl: "https://rickandmortyapi.com/api/character",
    },
    production: {
      characterUrl: "https://rickandmortyapi.com/api/character",
    },
  } as any;

  static getCharacterUrl(environment: string) {
    return this.getUrl(environment, "characterUrl");
  }

  private static getUrl(environment: string, url: string) {
    if (environment) return RepositoryConfigs.configuration[environment][url];
    else throw new Error("No environment found");
  }
}
Enter fullscreen mode Exit fullscreen mode

The RepositoryConfigs class provides a simple way to store environmental variables. An example of how to access a stored variable is by calling RepositoryConfigs.getUrl('beta', 'characterUrl').

RickAndMortyCharacterRepository.ts
import { IRickAndMortyCharacterRepository } from "./IRickAndMortyCharacterRepository";
import { RepositoryConfigs } from "./../RepositoryConfigs";
import { BaseReadRepository } from "../BaseReadRepository";
import { ResponseSchema } from "../../../domain/models/ResponseSchema";

export class RickAndMortyCharacterRepository
  extends BaseReadRepository<ResponseSchema>
  implements IRickAndMortyCharacterRepository
{
  url;

  constructor(environment: string) {
    super();
    this.url = RepositoryConfigs.getCharacterUrl(environment);
  }

  async getCharactersAsync(pageNumber: number): Promise<ResponseSchema> {
    const url = `${this.url}?page=${pageNumber}`;
    const characters = await this.getDataFromUrlAsync(url);

    return characters;
  }
}
Enter fullscreen mode Exit fullscreen mode

The RickAndMortyCharacterRepository class extends the base class BaseReadRepository and uses its method getDataFromUrlAsync(url) to make a request to the Rick and Morty character API. This class encapsulates the details of calling the API, such as constructing the URL with the specific endpoint and capturing the page number. An example usage of this class would be to call const characters = await new RickAndMortyCharacterRepository().getCharacters(pageNumber); to retrieve a page of characters.

CharacterListHandler.ts
import { IRickAndMortyCharacterRepository } from "./../../../infrastructure/repositories/rickandmortyrepository/IRickAndMortyCharacterRepository";
import { IHandler } from "../IHandler";
import { CharacterListRequest } from "../../models/CharacterListRequest";

export class CharacterListHandler implements IHandler {
  constructor(private rickAndMortyCharacterRepository: IRickAndMortyCharacterRepository) {}
  async runAsync(characterRequest: CharacterListRequest): Promise<any> {
    try {
      const response = await this.rickAndMortyCharacterRepository.getCharactersAsync(characterRequest.pageNumber);
      if (characterRequest.simulatedDelayMs > 0) await this.wait(characterRequest.simulatedDelayMs);

      return response.results;
    } catch (error: any) {
      throw new Error("Error getting character data");
    }
  }

  wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
}
Enter fullscreen mode Exit fullscreen mode

The CharacterListHandler class implements the IHandler interface and takes an instance of the IRickAndMortyCharacterRepository interface as a parameter. It defines a method runAsync that takes a CharacterListRequest object and returns a Promise. The runAsync method calls the getCharactersAsync method of the injected repository to retrieve a list of characters, with an optional simulated delay. If there is an error, it throws an error with a generic message. The class also contains a private wait method to pause execution for a specified number of milliseconds.

CharacterList.tsx
import React, { useEffect, useState } from "react";
import { Button, Card } from "react-bootstrap";
import { CharacterListHandler } from "../../../domain/handlers/RickAndMortyHandlers/CharacterListHandler";
import { Character } from "../../../domain/models/Character";
import { CharacterListRequest } from "../../../domain/models/CharacterListRequest";
import { RickAndMortyCharacterRepository } from "../../../infrastructure/repositories/rickandmortyrepository/RickAndMortyCharacterRepository";
import { useHelper } from "../../hooks/useHelper";
import { Loader } from "../utilities/loader/Loader";

export const CharacterList = () => {
  const helper = useHelper();
  const [loading, setLoading] = useState(true);
  const [characterData, setCharacterData] = useState<Character[]>([]);
  const [pageNumber, setPageNumber] = useState(1);

  const characterListHandler = new CharacterListHandler(new RickAndMortyCharacterRepository(helper.getEnvironment()));

  const onLoadingComplete = (data: any) => {
    // processDepots(data);
    console.log("Data loaded: ", data);
    setCharacterData(data);
    setLoading(false);
  };
  const onLoadingErrored = (error: Error) => {
    console.log(error.message);
  };

  useEffect(() => {
    setLoading(true);
  }, [pageNumber]);

  return (
    <>
      <Card>
        <Card.Header>Character loader with 5 second delay</Card.Header>
        <Card.Body>
          {loading && (
            <Loader
              handler={characterListHandler}
              handlerData={new CharacterListRequest(500, pageNumber)}
              onComplete={onLoadingComplete}
              onErrored={onLoadingErrored}
              failureMessage="Error loading character data"
              canRetry={true}
            />
          )}
          {!loading && characterData && (
            <>
              <Button className="me-3" onClick={() => setPageNumber(pageNumber > 0 ? pageNumber - 1 : pageNumber)}>
                Load previous page
              </Button>
              <Button onClick={() => setPageNumber(pageNumber + 1)}>Load next page</Button>
              <h3>Character Data Loaded</h3>
              <ul>
                {characterData.map((character) => (
                  <li key={character.id}>
                    {character.name} - {character.location.url}
                  </li>
                ))}
              </ul>
            </>
          )}
        </Card.Body>
      </Card>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The CharacterList react component uses the Loader component to load data from the Rick And Morty character api, and demonstrates how to manage loading state. It dynamically updates the Loader component based on user interaction, by re-initializing with new parameters when the page number changes. The loaded data is displayed as a list of characters with their names and location URLs.

Step 7: Writing some unit tests

import { RepositoryConfigs } from "../../infrastructure/repositories/RepositoryConfigs";
import { CharacterListHandler } from "../../domain/handlers/RickAndMortyHandlers/CharacterListHandler";
import { RickAndMortyCharacterRepository } from "../../infrastructure/repositories/rickandmortyrepository/RickAndMortyCharacterRepository";
import axios from "axios";
import {
  setupTestEnvironment,
  restoreEnvironment,
  mockResolvedValue,
  Verb,
  testEnvironment,
  getAxiosHeaders,
  mockRejectedValue,
} from "./TestBase";
import { CharacterListRequest } from "../../domain/models/CharacterListRequest";

jest.mock("axios");

describe("Character List API Test Suite", () => {
  beforeEach(() => {
    setupTestEnvironment();
  });

  afterEach(() => {
    restoreEnvironment();
  });

  it("should return character data", async () => {
    mockResolvedValue(getValidCharacterDataResponse(), Verb.GET);

    const rickAndMortyCharacterRepository = new RickAndMortyCharacterRepository(testEnvironment);
    const characterListHandler = new CharacterListHandler(rickAndMortyCharacterRepository);

    const characterList = await characterListHandler.runAsync(new CharacterListRequest(0, 0));

    expect(axios.get).toHaveBeenCalledWith(
      expect.stringContaining(RepositoryConfigs.getCharacterUrl(testEnvironment)),
      getAxiosHeaders()
    );
    expect(characterList).toEqual(getValidCharacterDataResponse().data.results);
  });
  it("should throw error when network fails", async () => {
    mockRejectedValue(new Error("Test axios error"), Verb.GET);
    const rickAndMortyCharacterRepository = new RickAndMortyCharacterRepository(testEnvironment);
    const characterListHandler = new CharacterListHandler(rickAndMortyCharacterRepository);

    await expect(characterListHandler.runAsync(new CharacterListRequest(0, 0))).rejects.toThrow(
      "Error getting character data"
    );
  });
});

const getValidCharacterDataResponse = () => {
  return {
    data: {
      info: {
        count: 671,
        pages: 34,
        next: "https://rickandmortyapi.com/api/character/?page=2",
        prev: "",
      },
      results: [
        {
          id: 1,
          name: "Rick Sanchez",
          status: "Alive",
          species: "Human",
          type: "",
        },
        {
          id: 2,
          name: "Morty Smith",
          status: "Alive",
          species: "Human",
          type: "",
        },
      ],
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

I have created some basic unit tests to demonstrate how to test API calls. These tests are located in the integration folder because they test the interaction between the handler and the repository. The tests use mock axios responses. The positive test ensures that the handler is calling the correct URL and returns the mocked data, while the negative test tests error handling. Note that this is not a comprehensive test suite but rather serves as an example of how to mock repository interfaces.

Summary

The React Loading Component is a reusable React component designed to handle data loading from external APIs. It provides a loading indicator while data is being retrieved and displays an error message if the data retrieval fails. It also allows for easy retrying of data retrieval and supports custom error messages. The component can be easily configured and customized to fit various use cases. The code is open-source and available on GitHub.

I am new to React and TypeScript and still learning. If you have any suggestions or improvements, please feel free to add them to the comments or contribute to the GitHub repository. If you made it this far, well done and thank you for reading.

Top comments (0)