DEV Community

Cover image for The Definitive Guide to Make API Calls in React
Lucas Wolff
Lucas Wolff

Posted on • Originally published at wolff.fun

The Definitive Guide to Make API Calls in React

Understanding how to deal with API calls in web applications is a crucial skill to have. There are lots of different libraries that help you through this process, but sometimes they are not very beginner-friendly.

When working with vanilla JavaScript, you'll probably be using a library like Fetch or Axios to make API requests. In React you can also use them, and the challenge is how to organize the code around these libraries to make it as readable, extensible and decoupled as possible.

This is not a very intuitive task. It's very common for new developers that are starting with React to make API requests like this:

// ❌ Don't do this

const UsersList = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("/users").then((data) => {
      setUsers(users);
    });
  }, []);

  return (
    <ul>
      {users.map(user => (
        <li>{user.name}<li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

The above approach works, and is very common even in business-level codebases. But there are some downsides of using it:

  • The data is stored in the local state
    • Every API call in other components will require a new local useState
  • The request library (Fetch) is called directly in the component
    • If you change the library to Axios, for example, then every component will need to be refactored
    • The same applies to the endpoint, if it changes you'll need to refactor it in many places
  • A server-level request is being made in a presentational component
    • Components are intended to present data, not handle fetch logic
    • It's a good practice to have a single responsibility for each component, class and function
  • It's not clear what the request will return
    • You rely on the endpoint name to know what will be returned by the API

There are a lot of different ways to solve these problems. Today I'll be showing you my ideas and approaches to create a folder and file structure that is reliable and scalable, and you can apply it—or the idea behind it—even on frameworks like Next.js.

The scenario for our example

To understand and glue all the concepts, let's progressively build a Grocery List app. The app will have the following features:

  • List existing items;
  • Add new item;
  • Remove item;
  • Mark item as done;

For the styles, I'll be using TailwindCSS. To simulate API requests Mirage JS will be used, which is a very easy to use and useful API mocking library. To call this API, we're going to use Fetch.

All of the examples are on my GitHub, so feel free to clone the repository and play with it. The details of how to run it are described in the README file.

The final result will look like this:

Grocery List App

Creating the API endpoints

This application will need 4 API endpoints:

  1. GET /api/grocery-list - Retrieve all items
  2. POST /api/grocery-list - Create a new item
  3. PUT /api/grocery-list/:id/done - Mark the item with id equals to :id as done
  4. DELETE /api/grocery-list/:id - Removes the item with id equals to :id

The following examples are the most basic case of calling APIs. It's not the best one but we'll refactor the code as we go, so you'll understand better all the concepts. Also, we're not focusing on the presentation layer, that is, the actual JSX of the component. It surely can be improved, but it's not the focus of this article.

1. Retrieving all the items

A good place to add the first call is on the useEffect of the component, and add a refresh state as parameter, so every time this state changes, we'll refetch the items:

// src/App.jsx

const App = () => {
  const [items, setItems] = useState([]);
  const [refetch, setRefetch] = useState(false);

  useEffect(() => {
    fetch("/api/grocery-list")
      .then((data) => data.json())
      .then((data) => {
        setItems(data.items);
      });
  }, [refresh]);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

2. Creating a new item

When the user inputs the item title and clicks on the "Add" button, the application should dispatch a call to the API to create a new item, then fetch all the items again to show the new item:

// src/App.jsx

const App = () => {
  // ...
  const [title, setTitle] = useEffect("");

  const handleAdd = (event) => {
    event.preventDefault();

    fetch("/api/grocery-list", {
      method: "POST",
      body: JSON.stringify({ title }),
    }).then(() => {
      setTitle(""); // Empty the title input
      setRefresh(!refresh); // Force refetch to update the list
    });
  };

  return (
    // ...

    <form onSubmit={handleAdd}>
      <input
        required
        type="text"
        onChange={(event) => setTitle(event.target.value)}
        value={title}
      />
      <button type="submit">Add</button>
    </form>

    // ...
  );
};
Enter fullscreen mode Exit fullscreen mode

3. Marking an item as done

When the user clicks on the checkbox to mark the item as done, the application should dispatch a PUT request passing the item.id as a parameter on the endpoint. If the item is already marked as done, we don't need to make the request.

This is very similar to creating a new item, just the request method changes:

// src/App.jsx

const App = () => {
  // ...

  const handleMarkAsDone = (item) => {
    if (item.isDone) {
      return;
    }

    fetch(`/api/grocery-list/${item.id}/done`, {
      method: "PUT",
    }).then(() => {
      setRefresh(!refresh); // Force refetch to update the list
    });
  };

  return (
    // ...

    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <label>
            {/* Checkbox to mark the item as done */}
            <input
              type="checkbox"
              checked={item.isDone}
              onChange={() => handleMarkAsDone(item)}
            />
            {item.title}
          </label>
        </li>
      ))}
    </ul>

    // ...
  );
};
Enter fullscreen mode Exit fullscreen mode

4. Removing an item

This is pretty much the same as we did on marking an item as done, but with the DELETE method. When clicking on the "Delete" button, the application should call a function that dispatches the API call:

// src/App.jsx

const App = () => {
  // ...

  const handleDelete = (item) => {
    fetch(`/api/grocery-list/${item.id}`, {
      method: "DELETE",
    }).then(() => {
      setRefresh(!refresh); // Force refetch to update the list
    });
  };

  return (
    // ...

    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <label>
            {/* Checkbox to mark the item as done */}
            <input type="checkbox" onChange={() => handleMarkAsDone(item)} />
            {item.title}
          </label>

          {/* Delete button */}
          <button onClick={() => handleDelete(item)}>Delete</button>
        </li>
      ))}
    </ul>

    // ...
  );
};
Enter fullscreen mode Exit fullscreen mode

Final code for the first part of the example

The final code should look like this:

// src/App.jsx

const App = () => {
  const [items, setItems] = useState([]);
  const [title, setTitle] = useState("");
  const [refresh, setRefresh] = useState(false);

  // Retrieve all the items
  useEffect(() => {
    fetch("/api/grocery-list")
      .then((data) => data.json())
      .then(({ items }) => setItems(items));
  }, [refresh]);

  // Adds a new item
  const handleAdd = (event) => {
    event.preventDefault();

    fetch("/api/grocery-list", {
      method: "POST",
      body: JSON.stringify({ title }),
    }).then(() => {
      setRefresh(!refresh);
      setTitle("");
    });
  };

  // Mark an item as done
  const handleMarkAsDone = (item) => {
    if (item.isDone) {
      return;
    }

    fetch(`/api/grocery-list/${item.id}/done`, {
      method: "PUT",
    }).then(() => {
      setRefresh(!refresh);
    });
  };

  // Deletes an item
  const handleDelete = (item) => {
    fetch(`/api/grocery-list/${item.id}`, {
      method: "DELETE",
    }).then(() => {
      setRefresh(!refresh);
    });
  };

  return (
    <>
      <form onSubmit={handleAdd}>
        <input
          required
          type="text"
          onChange={(event) => setTitle(event.target.value)}
          value={title}
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={item.isDone}
                onChange={() => handleMarkAsDone(item)}
              />
              {item.title}
            </label>
            <button onClick={() => handleDelete(item)}>delete</button>
          </li>
        ))}
      </ul>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

First Refactor: Creating Services

Now that we already have everything in place and working, let's refactor the code.

The first thing that we can do to make the code better is to create a service for the API calls. Services are basically JavaScript functions that are responsible for calling APIs.

This is useful because if you need to call the API in other places, you just call the service instead of copy-paste the whole fetch call.

// src/services/grocery-list.js

const basePath = "/api/grocery-list";

export const getItems = () => fetch(basePath).then((data) => data.json());

export const createItem = (title) =>
  fetch(basePath, {
    method: "POST",
    body: JSON.stringify({ title }),
  });

export const markItemAsDone = (itemId) =>
  fetch(`${basePath}/${itemId}/done`, {
    method: "PUT",
  });

export const deleteItem = (itemId) =>
  fetch(`${basePath}/${itemId}`, {
    method: "DELETE",
  });
Enter fullscreen mode Exit fullscreen mode

Note that the services are returning a Promise and all the state calls were removed. We also replaced the repetitive base path of the API endpoints with a constant.

Now let's replace the old fetch calls on the component with the new services:

// src/App.jsx

// Importing the services
import {
  createItem,
  deleteItem,
  getItems,
  markItemAsDone,
} from "./services/grocery-list";

const App = () => {
  // ...

  useEffect(() => {
    // Service call
    getItems().then(({ items }) => {
      setItems(items);
    });
  }, [refresh]);

  const handleAdd = (event) => {
    event.preventDefault();

    // Service call
    createItem(title).then(() => {
      setRefresh(!refresh);
      setTitle("");
    });
  };

  const handleMarkAsDone = (item) => {
    if (item.isDone) {
      return;
    }
    // Service call
    markItemAsDone(item.id).then(() => {
      setRefresh(!refresh);
    });
  };

  const handleDelete = (item) => {
    // Service call
    deleteItem(item.id).then(() => {
      setRefresh(!refresh);
    });
  };

  // ...
};
Enter fullscreen mode Exit fullscreen mode

This is much more readable and testable. You can test each service individually instead of testing the component as a whole. Also, it's much easier to understand what the code is supposed to do, for example:

// Get the items, then set the items.
getItems().then(({ items }) => {
  setItems(items);
});
Enter fullscreen mode Exit fullscreen mode

Second Refactor: Abstracting the HTTP call

The grocery-list service is heavily relying on the Fetch library. If we decide to change it to Axios, all the calls should change. Also, the service layer doesn't need to know how to call the API, but only which API should be called.

To avoid mixing these responsibilities, I like to create an API Adapter. The name actually doesn't matter—the goal here is to have a single place where the API's HTTP calls are configured.

// src/adapters/api.js

const basePath = "/api";

const api = {
  get: (endpoint) => fetch(`${basePath}/${endpoint}`),
  post: (endpoint, body) =>
    fetch(`${basePath}/${endpoint}`, {
      method: "POST",
      body: body && JSON.stringify(body),
    }),
  put: (endpoint, body) =>
    fetch(`${basePath}/${endpoint}`, {
      method: "PUT",
      body: body && JSON.stringify(body),
    }),
  delete: (endpoint) =>
    fetch(`${basePath}/${endpoint}`, {
      method: "DELETE",
    }),
};

export { api };
Enter fullscreen mode Exit fullscreen mode

This is the only file in the entire application that deals with HTTP calls. The other files that need to call the API only need to call these methods.

Now if you decide to replace Fetch with Axios, you just change this single file and you're good to go.

On the test side, now it's possible to test each API method individually without relying on the services call.

Talking about services, let's replace the old fetch calls with the new api. ones.

// src/services/grocery-list

import { api } from "../adapters/api";

const resource = "grocery-list";

export const getItems = () => api.get(resource).then((data) => data.json());

export const createItem = (title) => api.post(resource, { title });

export const markItemAsDone = (itemId) => api.put(`${resource}/${itemId}/done`);

export const deleteItem = (itemId) => api.delete(`${resource}/${itemId}`);
Enter fullscreen mode Exit fullscreen mode

Wow, much cleaner! Note that some responsibilities that are on the request level are not here anymore, like converting a JSON object to a string. This was not the services' responsibility, and now the API layer is doing this.

Again, the code has become more readable and testable.

Third Refactor: Creating Hooks

We have the services and the API layers in place, now let's improve the presentation layer, that is, the UI component.

The components are currently calling the services directly. This works fine but holding the state and calling the service is more like a feature of your application instead of a responsibility of each component that needs to call the API.

The first hook that we're going to create is the useGetGroceryListItems(), which contains the getItems() API call.

// src/hooks/grocery-list.js

// Default module import
import * as groceryListService from "../services/grocery-list";

export const useGetGroceryListItems = () => {
  const [items, setItems] = useState([]);
  const [refresh, setRefresh] = useState(false);

  useEffect(() => {
    groceryListService.getItems().then(({ items }) => {
      setItems(items);
    });
  }, [refresh]);

  const refreshItems = () => {
    setRefresh(!refresh);
  };

  return { items, refreshItems };
};
Enter fullscreen mode Exit fullscreen mode

Notice that we basically copied the behavior that was previously on the component to the new hook. We also needed to create the refreshItems(), so we can keep the data updated when we want instead of calling the service directly again.

We're also importing the service module to use it as groceryListService.getItems(), instead of calling just getItems(). This is because our hooks will have similar function names, so to avoid conflicts and also improve the readability, the whole service module is being imported.

Now let's create rest of the hooks for the other features (create, update and delete).

// src/hooks/grocery-list.js

export const useCreateGroceryListItem = () => {
  const createItem = (title) => groceryListService.createItem(title);

  return { createItem };
};

export const useMarkGroceryListItemAsDone = () => {
  const markItemAsDone = (item) => {
    if (item.isDone) {
      return;
    }
    groceryListService.markItemAsDone(item.id);
  };

  return { markItemAsDone };
};

export const useDeleteGroceryListItem = () => {
  const deleteItem = (item) => groceryListService.deleteItem(item.id);

  return { deleteItem };
};
Enter fullscreen mode Exit fullscreen mode

Then we need to replace the service calls with the hooks in the component.

// src/App.jsx

// Hooks import
import {
  useGetGroceryListItems,
  useCreateGroceryListItem,
  useMarkGroceryListItemAsDone,
  useDeleteGroceryListItem,
} from "./hooks/grocery-list";

const App = () => {
  // ...
  const { items, refreshItems } = useGetGroceryListItems();
  const { createItem } = useCreateGroceryListItem();
  const { markItemAsDone } = useMarkGroceryListItemAsDone();
  const { deleteItem } = useDeleteGroceryListItem();

  // ...

  const handleMarkAsDone = (item) => {
    // Validation moved to hook and passing `item` instead of `item.id`
    markItemAsDone(item).then(() => refreshItems());
  };

  const handleDelete = (item) => {
    // Passing `item` instead of `item.id`
    deleteItem(item).then(() => refreshItems());
  };

  // ...
};
Enter fullscreen mode Exit fullscreen mode

And that's it. Now the application is taking advantage of the hooks, which is useful because if you need the same feature in other components, you just call it.

If you're using a state management solution like Redux, Context API, or Zustand for example, you can make the state modifications inside the hooks instead of calling them at the component level. This helps to make things clearer and very well splitted between responsibilities.

Last Refactor: Adding the Loading State

Our application is working fine, but there's no feedback to the user during the waiting period of the API request and response. One solution to this is adding a loading state to each hook to inform the actual API request state.

After adding the loading state to each hook, the file will look like this:

// src/hooks/grocery-list.js

export const useGetGroceryListItems = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state
  const [items, setItems] = useState([]);
  const [refresh, setRefresh] = useState(false);

  useEffect(() => {
    setIsLoading(true); // Adding loading state
    groceryListService.getItems().then(({ items }) => {
      setItems(items);
      setIsLoading(false); // Removing loading state
    });
  }, [refresh]);

  const refreshItems = () => {
    setRefresh(!refresh);
  };

  return { items, refreshItems, isLoading };
};

export const useCreateGroceryListItem = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state

  const createItem = (title) => {
    setIsLoading(true); // Adding loading state
    return groceryListService.createItem(title).then(() => {
      setIsLoading(false); // Removing loading state
    });
  };

  return { createItem, isLoading };
};

export const useMarkGroceryListItemAsDone = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state

  const markItemAsDone = (item) => {
    if (item.isDone) {
      return;
    }

    setIsLoading(true); // Adding loading state
    return groceryListService.markItemAsDone(item.id).then(() => {
      setIsLoading(false); // Removing loading state
    });
  };

  return { markItemAsDone, isLoading };
};

export const useDeleteGroceryListItem = () => {
  const [isLoading, setIsLoading] = useState(false); // Creating loading state

  const deleteItem = (item) => {
    setIsLoading(true); // Adding loading state
    return groceryListService.deleteItem(item.id).then(() => {
      setIsLoading(false); // Removing loading state
    });
  };

  return { deleteItem, isLoading };
};
Enter fullscreen mode Exit fullscreen mode

Now we need to plug the loading state of the page to each hook:

// src/App.jsx

const App = () => {
  // ...

  // Getting loading states and renaming to avoid conflicts
  const { items, refreshItems, isLoading: isFetchingItems } = useGetGroceryListItems();
  const { createItem, isLoading: isCreatingItem } = useCreateGroceryListItem();
  const { markItemAsDone, isLoading: isUpdatingItem } = useMarkGroceryListItemAsDone();
  const { deleteItem, isLoading: isDeletingItem } = useDeleteGroceryListItem();

  // Read each loading state and convert them to a component-level value
  const isLoading = isFetchingItems || isCreatingItem || isUpdatingItem || isDeletingItem;

  // ...

  return (
    <>
      <form onSubmit={handleAdd}>
        <input
          required
          type="text"
          onChange={(event) => setTitle(event.target.value)}
          value={title}
          disabled={isLoading} {/* Loading State */}
        />
        <button type="submit" disabled={isLoading}> {/* Loading State */}
          Add
        </button>
      </form>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={item.isDone}
                onChange={() => handleMarkAsDone(item)}
                disabled={isLoading} {/* Loading State */}
              />
              {item.title}
            </label>
            <button onClick={() => handleDelete(item)} disabled={isLoading}> {/* Loading State */}
              delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Bonus Refactor: Create an Utility

Notice that in the useMarkGroceryListItemAsDone() hook we have a logic that tells if the item should be updated or not:

// src/hooks/grocery-list.js

const markItemAsDone = (item) => {
  if (item.isDone) {
    return; // Don't call the service
  }

  // Call the service and update the item
Enter fullscreen mode Exit fullscreen mode

This is not the ideal place for this logic because it can be needed in other places, forcing its duplication, and also it is a business logic of the application, and not a specific logic of this hook solely.

One possible solution is to create an util and add this logic there, so we only call the function in the hook:

// src/utils/grocery-list.js

export const shouldUpdateItem = (item) => !item.isDone;
Enter fullscreen mode Exit fullscreen mode

And then call this util in the hook:

export const useMarkGroceryListItemAsDone = () => {
  // ...

  const markItemAsDone = (item) => {
    // Calling the util
    if (!shouldUpdateItem(item)) {
      return;
    }

    // ...
Enter fullscreen mode Exit fullscreen mode

Now the hooks doesn't depend on any logic related to the business: they just call functions and return its values.

Wrapping Up

All the refactors that we did serve the purpose of improving the quality of the code, and make it more readable to humans. The code was working at first, but was not extensible and neither testable. These are very important characteristics of a great codebase.

We basically applied the Single-Responsibility Principle to the code in order to make it better. This code can be used as a foundation to build other services, connect with external APIs, create other components and so on.

As mentioned, you can also plug your state management solution here and manage the global state of the app in the hooks that we've created.

To improve the code even more, it's a good idea to work with React Query to take advantage of its features like caching, refetching and auto invalidation.

That's it! Hope you learned something new today to make your coding journey even better!


If you have any feedback or suggestions, send me an email

Great coding!

Top comments (7)

Collapse
 
manchicken profile image
Mike Stemle

It’s important to note that this code could really use better error handling. Establishing a pattern and practice for how to trap and display errors in the front-end is super important. I would put that into the essential category, too.

Defensive practices of always checking and handling for error cases is essential for quality software. Mobile devices tend to be on unreliable networks, so it is especially important to check for errors and make sure that your application can recover from a network outage.

Collapse
 
wolfflucas profile image
Lucas Wolff

Definitely! Error handling is a little bit confusing for beginners and a lot of people forget to apply them, ending up with apps breaking.

I'll be writing an article about some options to handle errors, like custom functions, hooks, etc. and where to place them.

Thanks for the contribution!

Collapse
 
dikamilo profile image
dikamilo

Each of your API promise call have memory leak, since you update state in potentially unmounted component or hook.

Collapse
 
wolfflucas profile image
Lucas Wolff

Hey @dikamilo!

I could keep track of a component's reference to check if it's mounted or not, but this is not under the scope of the article.

As I mentioned, a good approach to avoid dealing with these requests' specifics is to use React Query.

But thanks for the heads up.

Collapse
 
wolfflucas profile image
Lucas Wolff

btw, it's worth reading this thread related to this topic

github.com/facebook/react/pull/22114

Collapse
 
v_bird profile image
vbird

Great post. Functional Component itself is a declarative way to present us how a component is organized. So a declarative way to make a api call makes more sense to it.

Collapse
 
wolfflucas profile image
Lucas Wolff

Exactly! Glad that you liked the post.