DEV Community

Cover image for Separate API Layers In React Apps - 6 Steps Towards Maintainable Code
Johannes Kettmann
Johannes Kettmann

Posted on • Originally published at profy.dev

Separate API Layers In React Apps - 6 Steps Towards Maintainable Code

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.

Get the source code

Table Of Contents

  1. The Final Result
  2. The Initial State Of Our Code
    1. The Original (Messy) Code
    2. The Problems
  3. Refactoring To A Separate API Layer
    1. Step 1: Extract Query Hooks
    2. Step 2: Reuse Common Logic
    3. Step 3: Use Global Axios Instance
    4. Step 4: Set Common Headers In Global Axios Instance
    5. Step 5: Use Query Client With Global Config
    6. Step 6: Extract API Functions
  4. The Final State Of Separation
  5. Bonus: Further Decoupling By Wrapping Libraries

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 final result with a separate API layer

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.

Screenshot of the application

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

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

Problems with the current implementation

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.

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

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

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

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

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

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(...),
    ...
  );
Enter fullscreen mode Exit fullscreen mode

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

Around React email course for aspiring Junior devs

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

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

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

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

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

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

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) { ... }
Enter fullscreen mode Exit fullscreen mode
// 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) { ... }
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Get the source code

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.

Around React email course for aspiring Junior devs

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

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

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

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

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

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.

Around React email course for aspiring Junior devs

Top comments (4)

Collapse
 
mrdulin profile image
official_dulin

Prefer using useGetIssuesQuery and useResolveIssueMutation as API hook names

Collapse
 
cgatian profile image
Chaz Gatian

@mrdulin Do you have a good justification? IDE auto complete shows you it's a Query or Mutation. I don't see the benefit of adding hungarian notation.

Collapse
 
mrdulin profile image
official_dulin

This is just a naming convension for this kind of hooks. For example, my API service has getIssues and resolveIssue methods. So I will name these API hooks like use + service method + Query/Mutation.

Distinguish API hooks from other hooks by name.

Collapse
 
tnamdevnote profile image
Taek.B Nam

Great post! I was looking for ways to creating layered architecture using react-query, and this post explains it in an easy to understand manner.