Engineering 10 min

Consistent client-side interaction with API endpoints using Axios and React Query

Written by Diego Castillo
Dec 19, 2022
Diego Castillo

Share

share to linkedInshare to Twittershare to Facebook
Link copied
to clipboard

At Remote, Axios and React Query are used to build the “data layer” and interact with API endpoints. The combination of these two libraries allows us to easily fetch, cache, and update data in React applications.

As the Remote engineering team grew bigger, the lack of consistency when interacting with the data layer started to become more evident. There was no convention of where Axios services and React Query queries or mutations should be defined. Each development team created React Query hooks and named query keys depending on their preferences. While none of these was wrong on its own, using all of them in the same codebase was confusing and less predictable, adding an unnecessary level of cognitive load to the overall understanding of the application.

The goal of this blog post is to give an overview of the advantages of having a data layer API to address the following issues:

  • Standardize directory structure: Axios services and React Query hooks were inconsistently defined throughout the application. Sometimes, these would be defined in a services.js file, others in a hooks.js file, or even in a file of their own such as useMyHook.js. These files were located in the application domain, where they were used, or at the root level if used by multiple domains.

  • Enforce a naming convention: The resulting React Query hooks were inconsistently named as well. Hooks that were meant to be used by a specific user role were inadvertently named differently. For instance, hooks meant to be used by an “Admin” role were named useSubmitCompanyAsAdmin(...) (postfixed) or useAdminUpdateCompany() (prefixed) without any specific reason.

  • Consistent query keys: The query keys used by React Query to manage query caching followed no convention either. Some developers defined them as an array, while others didn’t. The name of the query key sometimes used camelCase notation, while in others it was kebab-case. Furthermore, the query keys were on occasion defined in the same place as the hook that used it, while others were in a separate file (e.g., constants.js).

Initial proposals

Static analysis / CI processes

The first proposed solution was to create a set of conventions regarding where to create these services and hooks and how to name them. These conventions were to be enforced through a combination of linting and CI processes (e.g., ESLint and Danger JS), thus standardizing their creation and usage.

The main downside of this approach is that it has a high cognitive load, as developers would need to learn where to create services/hooks, how to name them, and how to name their query keys, if any, or an error would be raised. Additionally, this would require a significant amount of effort to create and maintain rules that enforce the desired conventions.

Automated code generation

Automated code generation was suggested as a solution to decrease the number of decisions developers needed to make. Additionally, automated code generation would aid in creating a consistent development experience and API regardless of the domain and requirements.

This approach was implemented, allowing developers to run a script to create all the necessary Axios and React Query tools needed to interact with the server API:

bash
1npm run data-layer:new "GET /api/v1/users/:userId/invoices"

An illustrative generated file for the GET '/api/v1/users/:userId/invoices' endpoint would look like so:

javascript
1/**
2 * This file is autogenerated
3 */
4
5// Set up Axios API service
6const fetchUsersInvoicesByUserId = createApiService({
7 path: '/users/:userId/invoices',
8 version: '/api/v1',
9});
10
11// Automatically build a query key to be used with React Query
12export const fetchUsersInvoicesByUserIdQueryName = 'fetch_users_invoices_by_user_id';
13
14// Export hook allowing consumers to specify params and options as desired
15export const useFetchUsersInvoicesByUserId = (config = { params: {}, options: {} }) => {
16 return useQuery(
17 [fetchUsersInvoicesByUserIdAndByInvoiceIdQueryName, ...config.params],
18 () => fetchUsersInvoicesByUserId(config.params),
19 config.options
20 );
21};

This approach worked well because it allowed the standardization of how Axios and React Query were used in the codebase without requiring much input from developers, but it also had its drawbacks:

  • The major drawback of this implementation was that it added an extra level of indirection, as there was a need to translate an API endpoint such as GET '/api/v1/users/:userId/invoices to the hook name useFetchUsersInvoicesByUserId.

  • Automatically generating human-readable — and meaningful — variable and method names based solely on an API endpoint is difficult. For instance, longer endpoints resulted in generated method/hook names that were borderline unreadable.

  • Auto-generated files were git ignored, and because developers could remove, add, pull, or update existing endpoints at any point, there was a need to keep generated files in sync at all times.

Final solution

The Data Layer API

Regardless of the approach selected to solve the problems that we were facing, there was one thing that remained consistent: the endpoint. An endpoint name and its HTTP verb must match the server API. Inspired by GitHub’s Javascript SDK, we chose to solve the problems with automated code generation by instead creating a lightweight abstraction on top of Axios and React Query that allows seamless interaction with the server API.

The gist of the abstraction relies on creating centralized configuration files that define endpoints. For each HTTP verb, a configuration file was created: data-layer/GET.endpoints.js, data-layer/POST.endpoints.js, etc. These configuration files act as the SSoT for services, and might specify custom options needed for the endpoints to work properly, such as serialization rules or form data submission options:

javascript
1// data-layer/GET.endpoints.js
2module.exports = {
3 '/api/v1/users/:userId/invoices': { /* endpoint config */ },
4};

Next, a hook for each HTTP verb was created: useGet, usePost, etc. These hooks take care of creating a service, a standardized query key, and returning a React Query useQuery or useMutation result accordingly. An illustrative implementation of the useGet hook is shown below:

javascript
1export const useGet = (path, config = { params: {}, options: {} }) => {
2 // Use the configuration file to create a service
3 const service = createService(path, 'GET');
4 // Build a standardized query key
5 const queryKey = createQueryKey(path, config.params);
6
7 // Same return value as React Query `useQuery`
8 return useQuery(
9 queryKey,
10 (args) => service({ ...config.params, ...args }),
11 config.options
12 );
13};

These data layer hooks allow developers to use the configured endpoints as is, because all that’s needed is the endpoint name itself (no indirection):

javascript
1// Fetch the invoices of the user with `userId=1`
2const config = { params: { pathParams: { userId: 1 } } };
3const result = useGet('/api/v1/users/:userId/invoices', config);

In addition to the hooks, which allow to query or mutate data, a few other utility hooks have been created, such as the useInvalidateQuery, which internally uses the React Query useQueryClient hook to invalidate a query key by using only the endpoint name with support for query params, path params, and custom filters:

javascript
1const { invalidateQuery } = useInvalidateQuery();
2
3const options = {
4 onSuccess: () => {
5 // Invalidate invoices query for the user with `userId=1`
6 const config = { params: { pathParams: { userId: 1 } } };
7 invalidateQuery('/api/v1/users/:userId/invoices', config);
8 }
9};
10
11const mutation = usePost('/api/v1/users/:userId/invoices', options);
12
13const handleCreateInvoice = (bodyParams) => {
14 // Create an invoice for the user with `userId=1`
15 mutation.mutate({ pathParams: { userId: 1 }, bodyParams });
16};

The data layer was designed to be extendable in such a way that its API takes care of conventions by itself. This allows developers to compose custom hooks on top of the ones already exposed by the data layer or simply to create hooks that specify certain options by default.

Additionally, the data layer uses TypeScript, which makes it easier for developers to adopt the hooks; provides autocomplete suggestions when typing an endpoint name depending on the hook being used (i.e., depending on the configuration file the hook uses);and even raises an error if an endpoint that doesn’t exist is specified.

Autocomplete suggestions when typing an endpoint name

Autocomplete suggestions when typing an endpoint name.

Looking forward

The data layer API has become the single source of truth, or SSOT, for all things query-related and will allow standardization for how Axios and React Query are used in the codebase. In order to increase its adoption, an ESLint rule using no-restricted-imports was created to warn about React Query useQuery or useMutation imports, instead favoring use of the data layer hooks that internally use these.

Subscribe to receive the latest
Remote blog posts and updates in your inbox.