← Back to home

The Definitive Guide to Make API Calls in React

Take your API calls in React to the next level with simple actionable tips that are used in business applications.

Published at: Jun 19, 2023 12min read
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>
  );
};

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

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:

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>
  );
};

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>

    // ...
  );
};

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>

    // ...
  );
};

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>

    // ...
  );
};

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>
    </>
  );
};

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",
  });

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);
    });
  };

  // ...
};

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);
});

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 };

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}`);

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 };
};

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 };
};

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());
  };

  // ...
};

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 };
};

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>
    </>
  );
};

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

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;

And then call this util in the hook:

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

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

    // ...

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!