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:
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.
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
api
folder that contains a shared Axios instance and react-query client for common config, as well as fetch functions that send the requestsAs 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
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.tsximport { 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 APIconst 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 itconst 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 updateconst 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 updateconst 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 errorif (context?.currentIssuesPage) {queryClient.setQueryData(["issues", page], context.currentIssuesPage);}},onSettled: () => {// refetch data once the last mutation is finishedongoingMutationCount.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) => (<IssueRowkey={issue.id}issue={issue}resolveIssue={() => resolveIssueMutation.mutate(issue.id)}/>))}</tbody></Table><PaginationContainer><div><PaginationButtononClick={() => setPage(page - 1)}disabled={page === 1}>Previous</PaginationButton><PaginationButtononClick={() => 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.
First, we highly coupled the component to a variety of things related to the API. Like
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.
Currently, most of the code of our component is related to data fetching. In particular:
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.tsimport { 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.tsimport { 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.tsximport { 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) => (<IssueRowkey={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.
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:
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:
// features/issues/api/use-get-issues.tsimport { 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 hookexport function getQueryKey(page) {if (page === undefined) {return [QUERY_KEY];}return [QUERY_KEY, page];}// shared between useQuery and queryClient.prefetchQueryasync 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.tsimport { 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 useGetIssuesawait 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 }),...);
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:
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.tsimport 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 theNEXT_PUBLIC_API_BASE_URL
env variable is missing. So we immediately know that something is wrong before the code is deployed.
// api/axios.tsimport 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.
// .envNEXT_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.tsimport { 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 anymoreconst { 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.
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.tsimport 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.
// .envNEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.devNEXT_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.
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.tsimport { 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.tsexport 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.
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:
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.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.tsimport { 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.tsimport { 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.tsimport { 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;}
From my perspective, we’ve reached a satisfying degree of separation now. Let’s recap:
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.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.api
folder. We could in fact swap out Axios for something else and only change the code in the api
folder. At least ideally.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.tsexport const axios = Axios.create({baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,});
// api/issues.tsimport { 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.tsconst 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.tsimport { 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.