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
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.
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>;
}
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>
)}
</>
);
};
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 implementsIHandler.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;
}
}
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");
}
}
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;
}
}
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));
}
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>
</>
);
};
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: "",
},
],
},
};
};
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)