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:
-
The data is stored in the local state
- Every API call in other components will require a new local
useState
- Every API call in other components will require a new local
-
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:
Creating the API endpoints
This application will need 4 API endpoints:
-
GET /api/grocery-list
- Retrieve all items -
POST /api/grocery-list
- Create a new item -
PUT /api/grocery-list/:id/done
- Mark the item with id equals to :id as done -
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!
Top comments (7)
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.
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!
Each of your API promise call have memory leak, since you update state in potentially unmounted component or hook.
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.
btw, it's worth reading this thread related to this topic
github.com/facebook/react/pull/22114
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.
Exactly! Glad that you liked the post.