Clean code - React API calls
React is a library that is easy to use but hard to master. It offers flexibility to software engineers to structure their projects in any way they want. This can sometimes be a double-edged sword.
In almost every React project you need to make some API calls to fetch data from your backend and to display it.
Countless times I have seen API calls mixed with UI code resulting in messy code.
Something like this:
import axios from "axios";
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { Post } from "../components/Post";
import { Comments } from "../components/Comments";
import { PostResponse, CommentsResponse } from "../types";
export function PostWithComments() {
const { postId } = useParams<{ postId: string }>();
const [post, setPost] = useState<PostResponse>();
const [comments, setComments] = useState<CommentsResponse>();
const [error, setError] = useState(false);
useEffect(() => {
axios
.get<PostResponse>(`https://jsonplaceholder.typicode.com/posts/${postId}`)
.then((response) => setPost(response.data))
.catch(() => setError(true));
axios
.get<CommentsResponse>(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`)
.then((response) => setComments(response.data))
.catch(() => setError(true));
}, [postId]);
if (error) {
return <div>Oops, an error occurred</div>;
}
if (!post || !postComments) {
return <LoadingSpinner />;
}
return (
<section className="post-with-comments">
<Post post={post.data} />
<Comments comments={comments.data} />
</section>
);
}
Now, what is wrong with this code? It comes to small things, particularly this part:
axios
.get<PostResponse>(`https://jsonplaceholder.typicode.com/posts/${postId}`)
.then((response) => setPost(response.data))
.catch(() => setError(true));
axios
.get<CommentsResponse>(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`)
.then((response) => setComments(response.data))
.catch(() => setError(true));
It uses the same URL and base path to make an API call.
Now imagine you have a huge React project and this code is everywhere. One day you get a request from a client that they are migrating to API v2
.
What would you do?
Go through each instance of this code above and change the URL. And obviously, this isn't good.
Fix: A common client configuration
In every project you usually make API calls to a single backend instance, so it's natural to have a common Axios client configured which can be reused across the project components:
import axios from "axios";
export const client = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
});
In the example above, only baseURL is configured. There are many other configurable options for axios
client. For example, you can set headers
, or pass tokens from local storage, and many other things.
Now, let's see how we can use this common client:
import { client } from "../api/client";
...
export function PostWithComments() {
const { postId } = useParams<{ postId: string }>();
const [post, setPost] = useState<PostResponse>();
const [comments, setComments] = useState<CommentsResponse>();
const [error, setError] = useState(false);
useEffect(() => {
client
.get<PostResponse>(`/posts/${postId}`)
.then((response) => setPost(response.data))
.catch(() => setError(true));
client
.get<CommentsResponse>(`/comments?postId=${postId}`)
.then((response) => setComments(response.data))
.catch(() => setError(true));
}, [postId]);
...
}
As you can see this looks much better.
If tomorrow we need to use another version of the API we can just change it in one place. We can also set additional axios
configuration options in one place.
And the best thing is that now our UI code is separated from the API implementation. UI code should be just for presentation, it doesn't need to know about the base URL that we use and it doesn't need to know that we use axios
or fetch
for API calls.
This is a step in the right direction, but still not good enough.
The problem is still that in our UI code, we must specify the type of request like GET
, POST
, etc. We also must specify the endpoint.
Fix: Isolate API calls in separate functions
To do this we can reposition parts of the code in the file:
import { Post, Comments } from "./types";
import { client } from "./client";
async function getPost(postId: string) {
const response = await client.get<CommentsResponse>(
`/posts/${postId}`
);
return response.data;
}
async function getComments(postId: string) {
const response = await apiClient.get<CommentsResponse>(
`/comments?postId=${postId}`
);
return response.data;
}
export default { getPost, getComments };
With this, we have successfully hidden details like the type of HTTP request, endpoints, response types, etc.
Now, it's easy to import and use this:
import PostApi from "../api/post";
...
export function PostWithComments() {
const { postId } = useParams<{ postId: string }>();
const [post, setPost] = useState<PostResponse>();
const [comments, setComments] = useState<CommentsResponse>();
const [error, setError] = useState(false);
useEffect(() => {
PostApi.getPost(postId)
.then((response) => setPost(response.data))
.catch(() => setError(true));
PostApi.getComments(postId)
.then((response) => setComments(response.data))
.catch(() => setError(true));
}, [postId]);
...
}
With this, we got rid of unnecessary details from our UI code. Now we just call the functions that we need. The good thing is that we can reuse these functions across our project components.
This looks much better than the starting example.
Of course, there are additional things we can improve, but if you only do these 2 steps for a start, it is really going to make a huge difference in your code.
Comments ()