DEV Community

Cover image for Path To A Clean(er) React Architecture - API Layer & Fetch Functions
Johannes Kettmann
Johannes Kettmann

Posted on • Originally published at profy.dev

Path To A Clean(er) React Architecture - API Layer & Fetch Functions

The unopinionated nature of React is a two-edged sword:

  • On the one hand you get freedom of choice.
  • On the other hand many projects end up with a custom and often messy architecture.

This article is the second part of a series about software architecture and React apps where we take a code base with lots of bad practices and refactor it step by step.

Previously, we extracted an API client to share common configuration like the API base URL between all requests in the application.

In this article, we want to focus on separating API related code from the UI components.

Screenshot of code that contains a request method, response type, endpoint path and parameter handling

Bad Code: Mixed API and UI code

Let’s have a look at the bad code example. Here’s the result of the first refactoring step from the previous article: a component that fetches data from two API endpoints and renders the data.

import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { apiClient } from "@/api/client";
import { LoadingSpinner } from "@/components/loading";
import { ShoutList } from "@/components/shout-list";
import { UserResponse, UserShoutsResponse } from "@/types";
import { UserInfo } from "./user-info";

export function UserProfile() {
  const { handle } = useParams<{ handle: string }>();

  const [user, setUser] = useState<UserResponse>();
  const [userShouts, setUserShouts] = useState<UserShoutsResponse>();
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    apiClient
      .get<UserResponse>(`/user/${handle}`)
      .then((response) => setUser(response.data))
      .catch(() => setHasError(true));

    apiClient
      .get<UserShoutsResponse>(`/user/${handle}/shouts`)
      .then((response) => setUserShouts(response.data))
      .catch(() => setHasError(true));
  }, [handle]);

  if (hasError) {
    return <div>An error occurred</div>;
  }

  if (!user || !userShouts) {
    return <LoadingSpinner />;
  }

  return (
    <div className="max-w-2xl w-full mx-auto flex flex-col p-6 gap-6">
      <UserInfo user={user.data} />
      <ShoutList
        users={[user.data]}
        shouts={userShouts.data}
        images={userShouts.included}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why is this code bad?

It’s simple: API requests and UI code are mixed. A bit of API code here, a bit of UI there.

Screenshot of code that contains a request method, response type, endpoint path and parameter handling

But in fact, for the UI it shouldn’t matter how it get’s the data:

- It shouldn’t care whether a GET, a POST, or a PATCH request is sent.
- It shouldn’t care what the exact path of the endpoint is.
- It shouldn’t care how the request parameters are passed to the API.
- It shouldn’t even really care whether it connects to a REST API or a websocket.
Enter fullscreen mode Exit fullscreen mode

All of this should be an implementation detail from the perspective of the UI.

Solution: Extract functions that connect to the API

Extracting functions that connect to the API to a separate place will greatly help decouple API related code from UI code.

These functions will hide the implementation details like

- the request method
- the endpoint path or
- the data types.
Enter fullscreen mode Exit fullscreen mode

In order to have a clear separation we will locate these functions in a global API folder.

// src/api/user.ts

import { User, UserShoutsResponse } from "@/types";
import { apiClient } from "./client";

async function getUser(handle: string) {
  const response = await apiClient.get<{ data: User }>(`/user/${handle}`);
  return response.data;
}

async function getUserShouts(handle: string) {
  const response = await apiClient.get<UserShoutsResponse>(
    `/user/${handle}/shouts`
  );
  return response.data;
}

export default { getUser, getUserShouts };
Enter fullscreen mode Exit fullscreen mode

Now we use these fetch functions inside the component.

import UserApi from "@/api/user";

...

export function UserProfile() {
  const { handle } = useParams<{ handle: string }>();

  const [user, setUser] = useState<User>();
  const [userShouts, setUserShouts] = useState<UserShoutsResponse>();
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    if (!handle) {
      return;
    }

    UserApi.getUser(handle)
      .then((response) => setUser(response.data))
      .catch(() => setHasError(true));

    UserApi.getUserShouts(handle)
      .then((response) => setUserShouts(response))
      .catch(() => setHasError(true));
  }, [handle]);

  ...
Enter fullscreen mode Exit fullscreen mode

Why is this code better? Decoupling of UI and API code

You’re right. The code changes aren’t huge. We basically moved a bit of code around. The result might initially look like somewhat cleaner component code.

// before
apiClient.get<UserResponse>(`/user/${handle}`)

// after
UserApi.getUser(handle)
Enter fullscreen mode Exit fullscreen mode

But in fact, we started to effectively decouple the UI code from API related functionality.

I’m not getting tired of repeating myself: now the component doesn’t know a lot of API related details like

- the request method `GET`
- the definition of the data type `UserResponse`
- the endpoint path `/user/some-handle`
- or that the handle is passed to the API as URL parameter.
Enter fullscreen mode Exit fullscreen mode

Instead it calls a simple function UserApi.getUser(handle) that returns a typed result wrapped in a promise.

Additionally, we can reuse these fetch functions in multiple components or for different rendering approaches like client-side or server-side rendering.

Next refactoring steps

Yes, we took a big step forward separating our UI code from the API. We introduced an API layer and removed many implementation details from the UI component.

But we still have considerable coupling between the API layer and our components. For example, we transform the response data inside the components:

Screenshot of code that contains response.data inside component code

Now, this might not be the most impressive example. So let’s look at another one from the same code base:

Screenshot of code that filters part of the response data based on the entity’s type field

Here we can see how the response of the feed has an included field that contains users and images.

Not pretty, but sometimes we have to deal with such APIs.

But why should the component know?

Anyway, we’ll deal with that in the next article.

The React Job Simulator

Top comments (2)

Collapse
 
lxm7 profile image
Alex

for it to be properly decoupled from the UI, you'd ideally want the responses resolved outside of a components useEffect, i.e. in Redux as a memoised selector, or apollo-client or react-query, even just a custom hook as an additional abstration layer if none of the state/api/query managers/libs are neccesary.

Collapse
 
jkettmann profile image
Johannes Kettmann

You're completely right. I'll introduce react-query in a future step. The goal of this series of articles was to make the refactoring in tiny steps. These things tend to get complicated quickly :)