A Step-By-Step Guide to abstraction with a Generic Repository Pattern, TypeScript and React
Abstract
The Generic Repository Pattern is a design pattern used to create a data access layer for applications. It is very useful when designing a n-Tier architecture as it allows developers to create and maintain code of the data layer in a generic way, without having to repeat code each time. This pattern is especially useful when working with Typescript as it has the advantage to be a typed language. TypeScript is well suited for generics because it provides a static type system that allows for compile-time checking of types. Generics allow for the creation of reusable code that can be parameterized with different types, making it easier to write code that works with a variety of data types. TypeScript’s type system allows for type constraints to be placed on generics, ensuring that only certain types can be used as the parameter. Additionally, TypeScript provides type inference, which can infer the type of a generic parameter based on the type of the argument passed in. Overall, TypeScript’s type system provides a powerful set of tools for working with generics, making it well suited for generic programming.
The principle of abstraction in software engineering refers to the process of reducing complexity by hiding unnecessary details and focusing on essential features. In object-oriented programming, abstraction is achieved through the use of abstract classes and interfaces, which define a common set of properties and behaviors that can be shared by multiple classes.
Solution Outline
Each time I’ve been trying to implement a complete front-end API solution while using axios, I have struggled in making something simple and easy to understand for those who will maintain these web applications. Using Angular, I would normally use RxJs combined with the integrated HttpClient to make API calls. In the react world, nobody uses RxJs but it would be nice to have a fully functional generic repository that we could use to make calls to the back-end APIs. Well I have though about it and came up with implementation of a generic repository pattern that aims to provide an extra layer of abstraction over the axios HttpClient library which will allow developers to write code that is more consistent and maintainable. It also enables them to keep their business logic separated from the network calls and thus making code easier to debug and read.
Let’s get to it 👏👏👏👏
Step 1: Setting Up Your TypeScript & React Project
Setting up a React project can be a daunting task for beginners. It requires installing the right dependencies, setting up the development environment and writing code for setting it all up. The easiest way to create a new React app from scratch is by using a tool called “Create React App”. Here are the steps to create a new React app using Create React App:
- Install Node.js and npm (if they are not already installed)
- Open a terminal or command prompt
- Install Create React App by running the command
npm install -g create-react-app
- Create a new React app by running the command
npx create-react-app my-app --template typescript
(where "my-app" is the name of your app) - Navigate into the new app directory by running the command
cd my-app
- Start the development server by running the command
npm start
That’s it! Your new React app should now be running at http://localhost:3000. You can open the app in your code editor and start making changes to the code to customize it as needed.
Keep going, you’re almost there! 🏃
Step 2: Creating the Generic Repository Interface and Class
A generic repository interface and abstract class are used for providing a common set of methods for interacting with data sources. This enables developers to create an abstraction layer to access and manage data stored in different tables without the need to write extra code. The generic repository interface provides a contract (or blueprint) for the methods that the repository should expose, while the generic repository abstract class provides the concrete implementation of those methods. This allows for a separation of concerns, where the interface defines what needs to be done, while the class provides the how. By using generics, the repository can be reused across different entities and reduce code duplication. By using this combination of components, developers can quickly create applications that are capable of accessing and managing data from various sources.
Separation of concerns is a principle in software engineering that suggests that a software system should be broken down into distinct, loosely coupled components or modules, each of which focuses on a specific aspect or concern of the system.
Here is the BaseRepository class and it’s corresponding IBaseRepository interface:
import { AxiosResponse } from "axios";
import { HttpClient } from "./HttpClient";
import { CURRENT_BASE_URL } from "../constants/constants";
export interface IBaseRepository<T> {
get(id: any): Promise<ApiResponse<T>>;
getMany(): Promise<ApiResponse<T[]>>;
create(id: any, item: T): Promise<ApiResponse<T>>;
update(id: any, item: T): Promise<ApiResponse<T>>;
delete(id: any): Promise<ApiResponse<T>>;
}
export class ApiResponse<T> {
data?: T;
succeeded?: boolean;
errors: any;
}
const transform = (response: AxiosResponse): Promise<ApiResponse<any>> => {
return new Promise((resolve, reject) => {
const result: ApiResponse<any> = {
data: response.data,
succeeded: response.status === 200,
errors: response.data.errors,
};
resolve(result);
});
};
export abstract class BaseRepository<T> extends HttpClient implements IBaseRepository<T> {
protected collection: string | undefined;
public async get(id: string): Promise<ApiResponse<T>> {
const instance = this.createInstance();
const result = await instance.get(`${CURRENT_BASE_URL}/${this.collection}/${id}`).then(transform);
return result as ApiResponse<T>;
}
public async getMany(): Promise<ApiResponse<T[]>> {
const instance = this.createInstance();
const result = await instance.get(`${CURRENT_BASE_URL}/${this.collection}/`).then(transform);
return result as ApiResponse<T[]>;
}
public async create(id: string, item: T): Promise<ApiResponse<T>> {
const instance = this.createInstance();
const result = await instance.post(`${CURRENT_BASE_URL}/${this.collection}/`, item).then(transform);
return result as ApiResponse<T>;
}
public async update(id: string, item: T): Promise<ApiResponse<T>> {
const instance = this.createInstance();
const result = await instance.put(`${CURRENT_BASE_URL}/${this.collection}/${id}`, item).then(transform);
return result as ApiResponse<T>;
}
public async delete(id: any): Promise<ApiResponse<T>> {
const instance = this.createInstance();
const result = await instance.delete(`${CURRENT_BASE_URL}/${this.collection}/${id}`).then(transform);
return result as ApiResponse<T>;
}
}
So if you were wondering what the HttpClient class contains, well it’s in fact a axios wrapper that will allow us to access the axios instance and also use interceptors! 😈 Wait… what?
Step 3: Using axios to make HTTP calls to an API
In case you are wondering, axios is an open source, promise-based HTTP client library for JavaScript, which allows developers to make HTTP requests from the front-end to make HTTP requests and fetch data from web APIs. Axios simplifies the process of making and managing requests by providing a powerful API that makes it easy to create various kinds of HTTP requests on a front-end application. Axios also supports automatic transforms of data as well as automatic authorization headers, allowing developers to quickly make secure and optimized HTTP requests without having to write any extra code.
Overall, we first need to create an instance of Axios, in this case of type AxiosInstance. This will be done using the createInstance method and once created, we can use interceptors to the instance so that we can add to the header the JWT token.
A JWT (JSON Web Token) is a compact, URL-safe means of representing claims to be transferred between two parties. It can be used for authentication and authorization purposes in web applications.
Here is the wrapper class:
import axios, { AxiosInstance, AxiosResponse } from "axios";
import { CURRENT_BASE_URL } from "../constants/constants";
export abstract class HttpClient {
protected instance: AxiosInstance | undefined;
protected createInstance(): AxiosInstance {
this.instance = axios.create({
baseURL: CURRENT_BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
this.initializeResponseInterceptor();
return this.instance;
}
private initializeResponseInterceptor = () => {
this.instance?.interceptors.response.use(this.handleResponse, this.handleError);
const token = localStorage.getItem("jwtToken");
this.instance?.interceptors.request.use((config: any) => {
config.headers = {
Authorization: `Bearer ${token}`,
};
return config;
});
};
private handleResponse = ({ data }: AxiosResponse) => data;
private handleError = (error: any) => Promise.reject(error);
}
This way you can add all of the content headers to all of the http requests you will need to do and to top it off, you can also set your JWT token using an interceptor in a centralized way. This way you will gain alot of precious time with making API calls because all you will need to do is to instanciate a contrete class using this BaseRepository.
Step 4: Creating a concrete class that will be used for making API calls
Now all you need is to create a contrete Repository class that will inherit our BaseRepository class and thus inherit from all of it’s CRUD operation methods. Yay! Wait a second… What the heck are CRUD operations?
CRUD stands for Create, Read, Update and Delete. CRUD operations are the foundation of most web applications and APIs, as they are the basic operations to manage data storage.
CRUD operations are used in the development of RESTful web applications and microservices to create, read, update or delete data from a database. They are implemented using HTTP methods such as GET, POST, PUT and DELETE.
By using CRUD operations an application can access data stored in a database and manipulate it accordingly without having to manually write SQL queries. This makes them a powerful tool for managing data storage in web applications and APIs.
Here is an example of a concrete Repository class with UserRepository that is in fact, a BaseRepository of type IUser:
import { BaseRepository } from '../base/repository/BaseRepository';
export interface IUser {
id: number;
username: string;
}
class UserRepository extends BaseRepository<IUser> {
collection = 'user';
getMany() {
return super.getMany();
}
get(id: string) {
return super.get(id);
}
create(id: string, data: IUser) {
return super.create(id, data);
}
update(id: string, data: IUser) {
return super.update(id, data);
}
delete(id: string) {
return super.delete(id);
}
}
export default UserRepository;
Step 5: Using the Generic Repository in Your React Components
With its versatile and extensible design, the Generic Repository enables developers to quickly access data from any type of source while ensuring the safety of their code. Using it is very easy:
const UserList = () => {
const [users, setUsers] = useState<IUser[]>();
useEffect(() => {
const repository: UserRepository = new UserRepository(); // Hardcoded dependency
repository.getMany().then((response: ApiResponse<IUser[]>) => {
console.log(response);
setUsers(response.data);
});
}, []);
return (
<div>
<h2>Users</h2>
<ul>
{users?.map((user, idx) => (
<li key={idx}>{user.username}</li>
))}
</ul>
</div>
);
};
(edit) However, this solution is not ideal because there is one hardcoded dependency in our UserList component. We can clearly see this where the UserRepository is instantiated using the new keyword. What we want is to use Dependency Injection to avoid this.
Step 6: Using the React Context API for Dependency Injection
The React Context API facilitates dependency injection by allowing developers to provide and consume dependencies (like services or repositories) at various levels in the component hierarchy without props drilling. This built-in functionality eliminates the need for external libraries like InversifyJS, simplifying dependency management directly within React’s own ecosystem.
To use your UserRepository
class in a React application via the Context API, you will first need to instantiate it within a provider component and then provide it through the context so that any component within your application can access it. Here’s how you can do it step by step:
1. Create the Context
First, create a context specifically for the repository. This context will carry the instantiated repository object. You also need to create a Provider component that uses the Context’s Provider to pass down the instantiated UserRepository
. Here is where you instantiate your UserRepository
.
import React from "react";
import UserRepository from "../repositories/UserRepository";
// Create a context with a default value or null
export const UserRepositoryContext = React.createContext<UserRepository | undefined>(undefined);
// Define the props for the provider component
type ProviderProps = {
children: React.ReactNode;
};
export const UserRepositoryProvider = ({ children }: ProviderProps) => {
const userRepository = new UserRepository();
return <UserRepositoryContext.Provider value={userRepository}>{children}</UserRepositoryContext.Provider>;
};
2. Use the Provider
Wrap your application or the relevant component tree with the UserRepositoryProvider
to ensure that any child component can access the UserRepository instance.
import "./App.css";
import UserList from "./components/UserList";
import { UserRepositoryProvider } from "./context/UserRepositoryContext";
function App() {
return (
<div className="App">
<UserRepositoryProvider>
<UserList />
</UserRepositoryProvider>
</div>
);
}
export default App;
3. Consume the UserRepository in Components
Use the useContext
hook within any functional component to access the UserRepository.
import { useContext, useEffect, useState } from "react";
import { IUser } from "../repositories/UserRepository";
import { ApiResponse } from "../base/BaseRepository";
import { UserRepositoryContext } from "../context/UserRepositoryProvider";
const UserList = () => {
const [users, setUsers] = useState<IUser[]>();
const userRepository = useContext(UserRepositoryContext);
useEffect(() => {
userRepository?.getMany().then((response: ApiResponse<IUser[]>) => {
console.log(response);
setUsers(response.data);
});
}, [userRepository]);
return (
<div>
<h2>Users</h2>
<ul>
{users?.map((user, idx) => (
<li key={idx}>{user.username}</li>
))}
</ul>
</div>
);
};
export default UserList;
Using the Context API for dependency injection rather than hardcoding dependencies allows for more flexible, maintainable code. It decouples components from specific implementations of services or repositories, making them easier to test and adapt to changing requirements.
🛠️ You can view the complete solution code on my personal GitHub account here.
Conclusion
In conclusion, generics are the future of web application development. They provide developers with a powerful tool for creating efficient and scalable restful web APIs and microservices. Generic components help developers save time by allowing them to reuse code across multiple projects, and help them create robust systems that are less prone to errors. Generics also allow for quick iteration and flexibility when it comes to meeting new requirements.