In React application development, a common practice is to communicate with a back-end to perform CRUD operations. However, this process often involves writing and rewriting similar fetch logic across different components or services, leading to repetitive, hard-to-maintain code that's prone to errors. But what if there was a more elegant and efficient way to handle this communication?
Table of Contents
1 The Challenge
2 Understanding useHttpClient
3 Why Is It a Better Solution?
4 Implementation in Action
5 Conclusion
The Challenge:
Imagine having to implement multiple fetch calls in your app, each slightly different from the next. Over time, this approach can lead to a cluttered codebase, where managing changes becomes a nightmare, and the likelihood of introducing errors increases with each new request.
The Solution: Utilize Custom Hooks
This is where our approach changes the game. I propose the introduction of a custom hook in React: useHttpClient. This hook is designed to abstract the HTTP request logic, centralizing back-end communication in a reusable, easy-to-maintain place.
Understanding useHttpClient:
useHttpClient encapsulates the complexity of fetch requests, allowing developers to focus on business logic rather than on the details of server communication. By defining specific methods for each request type (GET, POST, PUT, DELETE), this hook offers a clear and concise interface for interacting with the back-end.
interface useHttpClientProps {
endpoint: string;
}
export interface CustomRequestProps {
body?: object;
endpoint?: string;
params?: Record<string, string>;
}
export const useHttpClient = (props: useHttpClientProps) => {
const baseUrl = process.env.REACT_APP_BACKEND_URL as string ?? "http://localhost:8080/api";
const prepareRequest = async (httpMethod: string, bodyObject?: object): Promise<RequestInit> => {
const requestOptions: RequestInit = {
method: httpMethod,
headers: {
'Content-type': 'application/json',
},
body: bodyObject ? JSON.stringify(bodyObject) : null,
};
return requestOptions;
}
const executeRequest = async <T>(request: RequestInit, endpoint?: string, params?: Record<string, string>): Promise<T> => {
const queryParams = new URLSearchParams(params).toString();
const requestUrl: string = baseUrl + props.endpoint + (endpoint ?? '') + (params ? `?${queryParams}` : '');
try {
const response = await fetch(requestUrl, request);
if (!response.ok) {
const errorMsg = await response.text();
throw new Error(errorMsg);
}
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) {
return await response.json() as T;
} else {
return {} as T;
}
} catch (error) {
console.error(error);
throw error;
}
}
const get = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("GET", body);
return executeRequest(request, endpoint, params);
}
const post = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("POST", body);
return executeRequest(request, endpoint, params);
}
const put = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("PUT", body);
return executeRequest(request, endpoint, params);
}
const del = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("DELETE", body);
return executeRequest(request, endpoint, params);
}
return { get, post, put, del };
}
Now that we see the bigger picture, let's break this down:
At the heart of our custom hook, useHttpClient, lie two critical functions: prepareRequest and executeRequest. These functions streamline the process of making HTTP requests, handling the intricacies of request preparation and execution.
prepareRequest
const prepareRequest = async (httpMethod: string, bodyObject?: object): Promise<RequestInit> => {
const requestOptions: RequestInit = {
method: httpMethod,
headers: {
'Content-type': 'application/json',
},
body: bodyObject ? JSON.stringify(bodyObject) : null,
};
return requestOptions;
}
The prepareRequest function constructs the options for a fetch request. It takes the HTTP method and an optional body object as arguments. The method parameter dictates the type of request (e.g., GET, POST), while the body object, if present, is stringified and included in the request options. This setup ensures that all requests are sent with the appropriate headers and payload format, laying the groundwork for effective communication with the backend.
executeRequest
const executeRequest = async <T>(request: RequestInit, endpoint?: string, params?: Record<string, string>): Promise<T> => {
const queryParams = new URLSearchParams(params).toString();
const requestUrl: string = baseUrl + props.endpoint + (endpoint ?? '') + (params ? `?${queryParams}` : '');
try {
const response = await fetch(requestUrl, request);
if (!response.ok) {
const errorMsg = await response.text();
throw new Error(errorMsg);
}
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) {
return await response.json() as T;
} else {
return {} as T;
}
} catch (error) {
console.error(error);
throw error;
}
}
The executeRequest function is where the magic happens. It takes the prepared request options, the specific endpoint, and any query parameters to construct the full URL. It then executes the fetch request, handling both success and error scenarios gracefully. For successful requests, it checks the response's content type and parses the JSON body accordingly, returning the parsed data. This method encapsulates error handling and response parsing, abstracting these concerns away from the consumer of our custom hook.
Request Methods: Simplifying API Calls
With prepareRequest and executeRequest laying the groundwork, our hook exposes four methods for performing API calls: get, post, put, and del. Each method is tailored to its specific HTTP verb, simplifying the process of making requests. The consumer of the hook doesn't need to worry about the details of request preparation and execution, as these are handled internally:
const get = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("GET", body);
return executeRequest(request, endpoint, params);
}
const post = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("POST", body);
return executeRequest(request, endpoint, params);
}
const put = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("PUT", body);
return executeRequest(request, endpoint, params);
}
const del = async <T>(customRequestProps?: CustomRequestProps): Promise<T> => {
const {body, endpoint, params} = customRequestProps ?? {};
const request = await prepareRequest("DELETE", body);
return executeRequest(request, endpoint, params);
}
By abstracting the complexities of fetch API calls into these streamlined operations, developers can focus on the logic specific to their application, making API interactions more straightforward and less error-prone.
Why Is It a Better Solution?
This abstraction not only cleans up our code, making it more readable and easier to follow, but it also significantly reduces the possibility of errors. By centralizing the request logic, we make our applications more robust and easier to test, improving development efficiency.
Implementation in Action
With useHttpClient defined, we'll show how this can be consumed by another domain-specific custom hook, like useTodolistService, to interact with our to-do list API. This modular approach allows us to reuse useHttpClient across different contexts, keeping our code DRY and focused.
export const useTodolistService = () => {
const { get, post, put, del } = useHttpClient({endpoint: "/items"});
const getAllItems = async (): Promise<Array<Item>> => {
return get<Array<Item>>();
}
const addItem = async (content: string): Promise<Item> => {
const request: CustomRequestProps = {body: {content: content} as Item};
return post<Item>(request);
}
const checkItem = async (id: number) => {
const request: CustomRequestProps = {endpoint: `/${id}/check`};
return put<void>(request);
}
const editContentItem = async (id: number, editedContent: string): Promise<Item> => {
const request: CustomRequestProps =
{
body: {content: editedContent} as Item,
endpoint: `/${id}`,
};
return put<Item>(request);
}
const deleteItem = async (id: number) => {
const request: CustomRequestProps = {endpoint: `/${id}`};
return del<void>(request);
}
return { getAllItems, addItem, checkItem, editContentItem, deleteItem };
}
Consuming useTodolistService
export const Todolist: FunctionComponent<TodolistProps> = () => {
const [items, setItems] = useState<Array<Item>>([]);
const { getAllItems } = useTodolistService();
useEffect(() => {
getAllItems()
.then(items => setItems(items));
},[]);
...
}
Conclusion
In the end, we circle back to the initial problem and how our custom hook solution not only addresses these issues but does so efficiently. Now, with useTodolistService, consuming our hook in components becomes a trivial task, allowing us to manage state and perform CRUD operations with simplicity and elegance.
I appreciate you all reading the entire article. I hope it has been useful to you and has provided you with a new tool for your projects. I leave you the repository here if you want to get the code used in this post.
In the next post I will show how I successfully integrated Auth0 into my app from the frontend to the backend, and I also will show how I added authorization headers using the custom hook explained in this post.
To find out when the next post is published, I invite you to follow me on my Dev.to profile or LinkedIn.
I will announce there when it is available.
See you next time!
Top comments (1)
well done, very informative!