Separate API Layers In React Apps6 Steps Towards Maintainable Code

Johannes KettmannPublished on 

When you work with API data in your React apps you can put all the code in the component. And that works fine. At least for small apps with limited logic.

But as soon as the codebase grows and the number of use cases increases you will run into problems because:

  • The UI code is tightly coupled with the data layer.
  • You have lots of duplicate code that you forget to update.
  • The component is full of API code and turns into an unreadable mess of spaghetti.

You probably know there are better options. And most likely you don’t just dump everything into the component. But you also don’t really understand what more experienced developers mean when they talk about “clean architecture” or “separate API layer”.

That sounds sophisticated… and intimidating. But actually, it’s not that hard. It only takes a few logical steps to go from messy entangled code to a separate API layer.

And that’s what we’ll do on this page. In the previous two articles on fetching and mutating data using a REST API, we built a component. It does its job but is admittedly quite messy. We’ll use this component and step-by-step refactor it to use a more layered architecture.

The Final Result

This article turned out a bit lengthy so let me give you a glance at the final result right away. The final code will be separated into

  • a global api folder that contains a shared Axios instance and react-query client for common config, as well as fetch functions that send the requests
  • custom hooks that use react-query but are isolated from the underlying API details
  • the components that use these custom hooks but are decoupled from any data fetching logic.

The Initial State Of Our Code

The Original (Messy) Code

As a foundation for our refactoring journey, we will take the component below that renders a table of “issues”. It is part of an error-tracking app similar to Sentry that I built for the React Job Simulator.

This component has a few advanced features like

  • pagination
  • prefetching the data for the next page and
  • optimistic updates when resolving an issue via the button at the right of each row.

It looks quite decent in the UI but the code isn’t as pretty. Have a look yourself. (No worries if you don’t understand a whole lot. That’s the point.)

Note: Although the original code is written in TypeScript the code examples here are in JavaScript to make them more accessible. The screenshots are kept in TypeScript though.

// features/issues/components/issue-list.tsx
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
export function IssueList() {
const [page, setPage] = useState(1);
// Fetch issues data from REST API
const issuePage = useQuery(
["issues", page],
async ({ signal }) => {
const { data } = await axios.get(
"https://prolog-api.profy.dev/v2/issue",
{
params: { page, status: "open" },
signal,
headers: { Authorization: "my-access-token" },
}
);
return data;
},
{ staleTime: 60000, keepPreviousData: true }
);
// Prefetch the next page of issues before the user can see it
const queryClient = useQueryClient();
useEffect(() => {
if (issuePage.data?.meta.hasNextPage) {
queryClient.prefetchQuery(
["issues", page + 1],
async ({ signal }) => {
const { data } = await axios.get(
"https://prolog-api.profy.dev/v2/issue",
{
params: { page, status: "open" },
signal,
headers: { Authorization: "my-access-token" },
}
);
return data;
},
{ staleTime: 60000 }
);
}
}, [issuePage.data, page, queryClient]);
const { items, meta } = issuePage.data || {};
// Resolve an issue with optimistic update
const ongoingMutationCount = useRef(0);
const resolveIssueMutation = useMutation(
(issueId) =>
axios.patch(
`https://prolog-api.profy.dev/v2/issue/${issueId}`,
{ status: "resolved" },
{ headers: { Authorization: "my-access-token" } }
),
{
onMutate: async (issueId) => {
ongoingMutationCount.current += 1;
await queryClient.cancelQueries(["issues"]);
// start optimistic update
const currentPage = queryClient.getQueryData([
"issues",
page,
]);
const nextPage = queryClient.getQueryData([
"issues",
page + 1,
]);
if (!currentPage) {
return;
}
const newItems = currentPage.items.filter(({ id }) => id !== issueId);
if (nextPage?.items.length) {
const lastIssueOnPage =
currentPage.items[currentPage.items.length - 1];
const indexOnNextPage = nextPage.items.findIndex(
(issue) => issue.id === lastIssueOnPage.id
);
const nextIssue = nextPage.items[indexOnNextPage + 1];
if (nextIssue) {
newItems.push(nextIssue);
}
}
queryClient.setQueryData(["issues", page], {
...currentPage,
items: newItems,
});
return { currentIssuesPage: currentPage };
},
onError: (err, issueId, context) => {
// restore previos state in case of an error
if (context?.currentIssuesPage) {
queryClient.setQueryData(["issues", page], context.currentIssuesPage);
}
},
onSettled: () => {
// refetch data once the last mutation is finished
ongoingMutationCount.current -= 1;
if (ongoingMutationCount.current === 0) {
queryClient.invalidateQueries(["issues"]);
}
},
}
);
return (
<Container>
<Table>
<thead>
<HeaderRow>
<HeaderCell>Issue</HeaderCell>
<HeaderCell>Level</HeaderCell>
<HeaderCell>Events</HeaderCell>
<HeaderCell>Users</HeaderCell>
</HeaderRow>
</thead>
<tbody>
{(items || []).map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
resolveIssue={() => resolveIssueMutation.mutate(issue.id)}
/>
))}
</tbody>
</Table>
<PaginationContainer>
<div>
<PaginationButton
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
Previous
</PaginationButton>
<PaginationButton
onClick={() => setPage(page + 1)}
disabled={page === meta?.totalPages}
>
Next
</PaginationButton>
</div>
<PageInfo>
Page <PageNumber>{meta?.currentPage}</PageNumber> of{" "}
<PageNumber>{meta?.totalPages}</PageNumber>
</PageInfo>
</PaginationContainer>
</Container>
);
}

As mentioned, we built this component in the previous two articles on fetching and mutating data using a REST API so you can find a detailed explanation there. Here let’s just quickly dive into some of the problems with putting all this code inside the component.

The Problems

First, we highly coupled the component to a variety of things related to the API. Like

  • the state management library (react-query)
  • the data fetching library (Axios)
  • the complete URLs of the endpoints including the base URL
  • the authorization via an access token in the header

A UI component doesn’t need to know about all this stuff.

On top of that, some of this code is duplicated even within this component. Each time we use Axios (in the query, prefetch query, and mutation), we retype the complete URL with minor differences, set authorization headers, and use a similar react-query config.

Of course, this is not the only component in our app that has to interact with the REST API.

Just one example: Imagine our REST API has a new version and we have to adjust the base URL from https://prolog-api.profy.dev/v2 to use v3 instead. That should be a simple change but we’d have to touch every component that fetches data.

You probably get the point. This code is hard to maintain and prone to become buggy.

Our goal now is to isolate our UI components from the logic related to the REST API. Ideally, we want to be able to make changes to the API requests without touching any of the UI code.

Refactoring To A Separate API Layer

Step 1: Extract Query Hooks

Currently, most of the code of our component is related to data fetching. In particular:

  1. Fetching the issue data.
  2. Updating an issue to status “resolved”.

So first, let’s split these two code blocks into their own custom hooks (as it’s recommended best practice). We create a hook that fetches the data in a file called use-get-issues.ts.

// features/issues/api/use-get-issues.ts
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
export function useGetIssues(page) {
const query = useQuery(
["issues", page],
async ({ signal }) => {
const { data } = await axios.get(
"https://prolog-api.profy.dev/v2/issue",
{
params: { page, status: "open" },
signal,
headers: { Authorization: "my-access-token" },
}
);
return data;
},
{ staleTime: 60000, keepPreviousData: true }
);
// Prefetch the next page!
const queryClient = useQueryClient();
useEffect(() => {
if (query.data?.meta.hasNextPage) {
queryClient.prefetchQuery(
["issues", page + 1],
async ({ signal }) => {
const { data } = await axios.get(
"https://prolog-api.profy.dev/v2/issue",
{
params: { page: page + 1, status: "open" },
signal,
headers: { Authorization: "my-access-token" },
}
);
return data;
},
{ staleTime: 60000 }
);
}
}, [query.data, page, queryClient]);
return query;
}

The filename and the name of function useGetIssues already tell us what the code inside is doing. This alone is a great improvement for readability.

Let’s have a look at the next custom hook that is used to resolve an issue. I spare you the details of onMutate and the other callbacks.

// features/issues/api/use-resolve-issues.ts
import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
export function useResolveIssue(page) {
const queryClient = useQueryClient();
const ongoingMutationCount = useRef(0);
return useMutation(
(issueId) =>
axios.patch(
`https://prolog-api.profy.dev/v2/issue/${issueId}`,
{ status: "resolved" },
{ headers: { Authorization: "my-access-token" } }
),
{
onMutate: ...,
onError: ...,
onSettled: ...,
}
);
}

For now, we simply extracted two code blocks to custom hooks. But even this simple change makes the component so much easier to read.

// features/issues/components/issue-list.tsx
import { useState } from "react";
import { useGetIssues, useResolveIssue } from "../../api";
export function IssueList() {
const [page, setPage] = useState(1);
const issuePage = useGetIssues(page);
const resolveIssue = useResolveIssue(page);
const { items, meta } = issuePage.data || {};
return (
<Container>
<Table>
<head>...</thead>
<tbody>
{(items || []).map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
resolveIssue={() => resolveIssue.mutate(issue.id)}
/>
))}
</tbody>
</Table>
<PaginationContainer>...</PaginationContainer>
</Container>
);
}

But the readability is not the only thing that improved. We just introduced our first layer.

The component is now isolated from the logic related to API fetching. The component doesn’t know anymore that we use Axios, what API endpoints are called, or the request configuration. It doesn’t have to care anymore about details like data prefetching or optimistic updates.

Step 2: Reuse Common Logic

We isolated the components from the API details by creating custom hooks. But that means we simply moved a lot of the problems to these hooks. One of these problems is duplicate code. For now, let’s tackle two parts:

  • The query keys that are used in the fetching and prefetching logic.
  • The fetch function that calls axios.get.

The fetch function is rather obvious. It’s a lot of duplicate code that just has a different page parameter in the GET request.

The query keys on the other hand may seem insignificant here. That’s barely code duplication. The problem is that these keys are also used in the useResolveIssue hook for the optimistic update.

So whenever we change the query keys in the useGetIssues hook we have to remember to update them in the useResolveIssue hook as well. And likely we’ll forget and introduce bugs that are hard to detect.

So let’s introduce two changes here:

  1. Instead of the hard-coded query keys let’s use a generator function.
  2. Extract the fetch function so we can reuse it.
// features/issues/api/use-get-issues.ts
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
const QUERY_KEY = "issues";
// this is also used to generate the query keys in the useResolveIssue hook
export function getQueryKey(page) {
if (page === undefined) {
return [QUERY_KEY];
}
return [QUERY_KEY, page];
}
// shared between useQuery and queryClient.prefetchQuery
async function getIssues(page, options) {
const { data } = await axios.get("https://prolog-api.profy.dev/v2/issue", {
params: { page, status: "open" },
signal: options?.signal,
headers: { Authorization: "my-access-token" },
});
return data;
}
export function useGetIssues(page) {
const query = useQuery(
getQueryKey(page),
({ signal }) => getIssues(page, { signal }),
{ staleTime: 60000, keepPreviousData: true }
);
// Prefetch the next page!
const queryClient = useQueryClient();
useEffect(() => {
if (query.data?.meta.hasNextPage) {
queryClient.prefetchQuery(
getQueryKey(page + 1),
({ signal }) => getIssues(page + 1, { signal }),
{ staleTime: 60000 },
);
}
}, [query.data, page, queryClient]);
return query;

The useResolveIssues hook now can also use the query key generator. That eliminates a likely source of future bugs.

Additionally, we also extract the fetch function.

// features/issues/api/use-resolve-issues.ts
import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as GetIssues from "./use-get-issues";
async function resolveIssue(issueId) {
const { data } = await axios.patch(
`https://prolog-api.profy.dev/v2/issue/${issueId}`,
{ status: "resolved" },
{ headers: { Authorization: "my-access-token" } }
);
return data;
}
export function useResolveIssue(page) {
const queryClient = useQueryClient();
const ongoingMutationCount = useRef(0);
return useMutation((issueId) => resolveIssue(issueId), {
onMutate: async (issued ) => {
ongoingMutationCount.current += 1;
// use the query key generator from useGetIssues
await queryClient.cancelQueries(GetIssues.getQueryKey());
const currentPage = queryClient.getQueryData(
GetIssues.getQueryKey(page)
);
const nextPage = queryClient.getQueryData(
GetIssues.getQueryKey(page + 1)
);
// let me spare you the rest
...
},
onError: ...,
onSettled: ...,
});
}

The result is more DRY code. But not only that.

Did you realize that we just added another isolation layer? By extracting the fetch functions we just decoupled the custom react-query hooks from the data-fetching logic.

For example, before we had useGetIssues tightly coupled to axios.

export function useGetIssues(page) {
const query = useQuery(
["issues", page],
({ signal }) => axios.get(...),
...
);

With the new code, we could switch from axios to fetch or even Firebase and wouldn’t have to touch the useGetIssues hook at all.

export function useGetIssues(page) {
const query = useQuery(
["issues", page],
({ signal }) => getIssues(page, { signal }),
...
);
You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

Step 3: Use Global Axios Instance

The next problem that we have to tackle is the duplication of API base URLs in each of the query hooks.

This again might seem insignificant for a small app. But imagine you have a dozen or more of these hooks and you have to change the base URL. There are many possible reasons for a changing base URL:

  • the API moved to another subdomain (unlikely)
  • there’s a new API version (likely)
  • we need to use different base URLs for development and production (very likely)

In any of these cases, we would have to touch each of the custom hooks and make sure not to forget any of them. In fact, while writing the previous blog posts I already forgot to change the version of another hook.

Oops, things slip through.

Ok, so how do we reuse the same base URL in all fetch functions? The easiest option is to use a shared instance of axios and set the base URL there.

In this case, we create a new global folder api and in it a axios.ts file. We create an instance of axios and set the baseURL option. The instance is exported so we can use it in any of the fetch functions.

// api/axios.ts
import Axios from "axios";
export const axios = Axios.create({
baseURL: "https://prolog-api.profy.dev/v2",
});

This still doesn’t allow us to have different base URLs for different environments (like development or production). So instead of hard-coding the URL we use an environment variable.

Note: The assert function acts as a safety net. It prevents the app from being built if the NEXT_PUBLIC_API_BASE_URL env variable is missing. So we immediately know that something is wrong before the code is deployed.

// api/axios.ts
import assert from "assert";
import Axios from "axios";
assert(
process.env.NEXT_PUBLIC_API_BASE_URL,
"env variable not set: NEXT_PUBLIC_API_BASE_URL"
);
export const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

A simple option to set the environment variable (at least in development) is using a .env file.

Note: Our app is a Next.js app that supports .env files out of the box. If you’re not using Next.js you might need to set up dotenv yourself.

// .env
NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev/v2

Now we can remove the base URL from the fetch functions getIssues, resolveIssue, and getProjects. It’s probably enough to see one of those.

// features/issues/api/use-get-issues.ts
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { axios } from "@api/axios";
import type { Page } from "@typings/page.types";
import type { Issue } from "@features/issues";
...
async function getIssues(page, options) {
// no need to add the base URL anymore
const { data } = await axios.get("/issue", {
params: { page, status: "open" },
signal: options?.signal,
headers: { Authorization: "my-access-token" },
});
return data;
}
export function useGetIssues(page) {
...
}

We just isolated our fetch functions from the base URL! We could swap the URL without changing a single line of code simply by adjusting an environment variable.

Step 4: Set Common Headers In Global Axios Instance

The next problem is that our fetch functions are tightly coupled to the authorization mechanism.

In our case, we just set a simple access token (which is currently hard-coded and checked in the repository… ouch). But most apps use a slightly more sophisticated approach.

In any case, it makes sense to decouple our fetch functions from the authorization mechanism and remove some duplicate code. Axios is of great help here as it supports request and response interceptors. We can use these to add the authorization header to any outgoing request.

// api/axios.ts
import assert from "assert";
import Axios, { AxiosRequestConfig } from "axios";
assert(
process.env.NEXT_PUBLIC_API_BASE_URL,
"env variable not set: NEXT_PUBLIC_API_BASE_URL"
);
assert(
process.env.NEXT_PUBLIC_API_TOKEN,
"env variable not set: NEXT_PUBLIC_API_TOKEN"
);
function authRequestInterceptor(config: AxiosRequestConfig) {
config.headers.authorization = process.env.NEXT_PUBLIC_API_TOKEN;
return config;
}
export const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
axios.interceptors.request.use(authRequestInterceptor);

Again we use an environment variable to store the access token.

// .env
NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev
NEXT_PUBLIC_API_TOKEN=my-access-token

And miraculously, we can remove the headers option from our fetch functions.

// features/issues/api/use-get-issues.ts
...
async function getIssues(page, options) {
const { data } = await axios.get("/issue", {
params: { page, status: "open" },
signal: options?.signal,
});
return data;
}
export function useGetIssues(page) { ... }
// features/issues/api/use-resolve-issues.ts
...
async function resolveIssue(issueId: string) {
const { data } = await axios.patch(
`/issue/${issueId}`,
{ status: "resolved" },
);
return data;
}
export function useResolveIssue(page) { ... }

So now, we isolated our fetch functions from the base URL as well as the authorization mechanism.

Step 5: Use Query Client With Global Config

Again a seemingly minor issue: Duplicate query configs that are used for all GET requests.

Even though this might seem minor, in fact, this caused a bug while I wrote this code. I forgot the second config in the screenshot above and weird things happened. It wasn’t easy to track that bug down.

To reuse these common configs we can create a global query client similar to what we did with Axios. We create a file api/query-client.ts and export a query client with the common options.

// api/query-client.ts
import { QueryClient } from "@tanstack/react-query";
const defaultQueryConfig = { staleTime: 60000 };
export const queryClient = new QueryClient({
defaultOptions: { queries: defaultQueryConfig },
});

Now we can remove the staleTime config from the fetch functions.

// features/issues/api/use-get-issues.ts
export function useGetIssues(page) {
const query = useQuery(
getQueryKey(page),
({ signal }) => getIssues(page, { signal }),
{ keepPreviousData: true }
);
// Prefetch the next page!
const queryClient = useQueryClient();
useEffect(() => {
if (query.data?.meta.hasNextPage) {
queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
getIssues(page + 1, { signal })
);
}
}, [query.data, page, queryClient]);
return query;
}

By moving the setup of the query client into the api folder next to the global Axios instance we now have a centralized place for everything related to API requests.

Almost everything at least.

Step 6: Extract API Functions

We already have isolated our UI component from the low-level data fetching code quite well. I’d like to take it one step further though and extract the fetch functions to a central place. This step is inspired by Redux Toolkit Query where the API is defined in a single place.

This step goes against the feature-driven folder structure that this project uses. So I’m not sure how beneficial it’ll be. But let’s see where this is going.

The downsides that I see with the current code are:

  • The fetch functions are in the same files as the react-query hooks. If we wanted to switch from Axios to another tool like fetch or even Firebase we’d have to touch all these hook files.
  • Multiple hooks in different files use the same endpoint. We would have to be careful not to forget one of the fetch functions if we’d ever change a shared endpoint. There are several options to solve this problem like extracting the endpoint into a shared constant, creating a function that returns the endpoint, or (as we’ll do) combining both fetch functions into a single file.

So let’s extract the fetch functions into separate files in the global api folder.

Here are all endpoints related to “issues” combined in one file.

// api/issues.ts
import { axios } from "./axios";
const ENDPOINT = "/issue";
export async function getIssues(page, filters, options) {
const { data } = await axios.get(ENDPOINT, {
params: { page, ...filters },
signal: options?.signal,
});
return data;
}
export async function resolveIssue(issueId) {
const { data } = await axios.patch(`${ENDPOINT}/${issueId}`, {
status: "resolved",
});
return data;
}

And here are the “projects” endpoints.

// api/projects.ts
import { axios } from "./axios";
const ENDPOINT = "/project";
export async function getProjects() {
const { data } = await axios.get(ENDPOINT);
return data;
}

As an example, our custom hook file use-get-issues.ts looks almost the same. Only the getIssues function has been replaced by the import from @api/issues.

// features/issues/api/use-get-issues.ts
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getIssues } from "@api/issues";
const QUERY_KEY = "issues";
export function getQueryKey(page) {
if (page === undefined) {
return [QUERY_KEY];
}
return [QUERY_KEY, page];
}
export function useGetIssues(page) {
const query = useQuery(
getQueryKey(page),
({ signal }) => getIssues(page, { status: "open" }, { signal }),
{ keepPreviousData: true }
);
// Prefetch the next page!
const queryClient = useQueryClient();
useEffect(() => {
if (query.data?.meta.hasNextPage) {
queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
getIssues(page + 1, { status: "open" }, { signal })
);
}
}, [query.data, page, queryClient]);
return query;
}

The Final State Of Separation

From my perspective, we’ve reached a satisfying degree of separation now. Let’s recap:

  1. The code that is close to the REST API are all located close to each other in the global api folder. This includes the Axios and query clients as well as the fetch functions that send the requests. If the API changes in any way (e.g. different version, base URL, headers, or endpoints) we can easily locate the files that need to be adjusted.
  2. Shared configuration of the requests or queries is now located in one place (api/axios.ts or api/query-client.ts). We don’t need to add it to every request or hook. Thus the risk of a wrong configuration is decreased and it’s easier to change for the entire app.
  3. The query hooks have no knowledge of the underlying library used for data fetching. They also don’t need to care about the API endpoints. All this is encapsulated inside the fetch functions in the api folder. We could in fact swap out Axios for something else and only change the code in the api folder. At least ideally.
  4. The UI component is decoupled from any data-fetching logic. Just from looking at the component’s code, you’d have no idea that the paginated table data is prefetched or the “Resolve Issue” mutation triggers an optimistic update.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

Bonus: Further Decoupling By Wrapping Libraries

Even though we’re in a good state already, we could take it a step further.

Note: I’m not a big fan of what follows as it’s too much overhead for too little potential future value. But I wanted to mention it for completion.

We already achieved a good separation between the fetch functions and the query hooks. As mentioned, the query hooks don’t have any knowledge about Axios being used or about request details like the endpoints.

But the fetch functions themselves aren’t decoupled from Axios yet. We directly export the Axios client and use it in the fetch functions.

// api/axios.ts
export const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
// api/issues.ts
import { axios } from "./axios";
export async function getIssues(...) {
const { data } = await axios.get(ENDPOINT, {
params: { page, ...filters },
signal: options?.signal,
});
return data;
}

If we wanted to replace Axios with something else we’d have to adjust all the fetch functions as well.

To create a further isolation layer we can simply wrap the axios client and only expose its methods indirectly.

// api/api-client.ts
const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
export const apiClient = {
get: (route, config) =>
axios.get(route, { signal: config?.signal, params: config?.params }),
post: (route, data, config) =>
axios.post(route, data, { signal: config?.signal }),
put: (route, data, config) =>
axios.put(route, data, { signal: config?.signal }),
patch: (route, data, config) =>
axios.patch(route, data, { signal: config?.signal }),
};

Note that we don’t pass the config object directly to axios. Otherwise, the fetch functions could use any config option that Axios supports. And that again would couple them to Axios.

The fetch function basically would stay the same.

// api/issues.ts
import { apiClient } from "./api-client";
export async function getIssues(...) {
const { data } = await apiClient.get(ENDPOINT, {
params: { page, ...filters },
signal: options?.signal,
});
return data;
}

Now we would have complete isolation between the API client and the fetch functions. We could swap out Axios without changing a single line in the fetch functions.

Similarly, our UI components are still coupled to react-query because the query hooks directly return the return value of useQuery.

// features/issues/api/use-get-issues.ts
...
export function useGetIssues(page: number) {
const query = useQuery(
getQueryKey(page),
({ signal }) => getIssues(page, { status: "open" }, { signal }),
{ keepPreviousData: true }
);
...
return query;
}

The component that uses this hook can use everything that’s in the return value of useQuery. So if we wanted to migrate away from react-query we’d have to adjust the query hooks as well as the components that use these hooks.

To introduce another isolation layer here we could again wrap the return value of the hook.

// features/issues/api/use-get-issues.ts
...
export function useGetIssues(page: number) {
const query = useQuery(
getQueryKey(page),
({ signal }) => getIssues(page, { status: "open" }, { signal }),
{ keepPreviousData: true }
);
// Prefetch the next page!
const queryClient = useQueryClient();
useEffect(() => {
if (query.data?.meta.hasNextPage) {
queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
getIssues(page + 1, { status: "open" }, { signal })
);
}
}, [query.data, page, queryClient]);
return {
data: query.data,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: async () => {
await query.refetch();
},
};
}

Note that we neither expose query.refetch directly nor its return value since that again would open the door for coupling.

Now we could (ideally) swap out react-query and only touch the query hooks. The components wouldn’t even notice.

The downside of these additional isolation layers is the overhead of creating and maintaining these wrappers. You saw that we need to be very careful what we expose if we want to achieve real isolation (e.g. the config param in the API client or the query.refetch function or its return value in the query hook). This is already hard to do with Vanilla JS and requires a lot of extra code. But with TypeScript, it’s even worse since you’d have to duplicate many types.

The advantage is obviously the ability to swap out single libraries. It’s unclear though how likely this is and how much of a benefit it provides. There’s still a chance that the new replacement library doesn’t support things the same way and you end up re-writing parts of the other code as well.

From my perspective introducing wrappers like these can make sense if it’s likely that a library has to be replaced at some point. Like a UI library that can greatly accelerate development speed at the beginning but could cause trouble as the design becomes more specific and deviates from the library’s defaults.

In our case, I don’t see the cost justified though.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.