DEV Community

Cover image for Transform Your React App’s Backend Communication with useHttpClient
Martin Jerez
Martin Jerez

Posted on

Transform Your React App’s Backend Communication with useHttpClient

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.
useHttpClient-applied

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

Consuming useTodolistService

export const Todolist: FunctionComponent<TodolistProps> = () => {

    const [items, setItems] = useState<Array<Item>>([]);
    const { getAllItems } = useTodolistService();

    useEffect(() => {
        getAllItems()
            .then(items => setItems(items));
    },[]);
...
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
hugaidas profile image
Victoria

well done, very informative!