This is a common problem that beginner React developers face when working on a new project. I will show here what you are doing and a method you can use to have a better and cleaner code (with tests!).
Let's suppose that we are developing a new blog application that will render a simple list of posts based on the response of our API. Usually what we have is this:
import { useEffect, useState } from 'react';
import axios from 'axios';
import { Post } from '../../types/post';
import Pagination from '../Pagination/Pagination';
import PostCard from '../PostCard/PostCard';
const DirBlogPosts: React.FC = () => {
const [page, setPage] = useState<number>(1);
const [posts, setPosts] = useState<Array<Post>>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
useEffect(() => {
(async () => {
try {
setIsLoading(true);
const { data } = await axios.get<Array<Post>>('https://example.com/posts', {
params: { page },
});
setPosts(data);
} catch (error) {
setIsError(true);
} finally {
setIsLoading(false);
}
})();
}, [page]);
if (isLoading) {
return <p>Loading posts...</p>;
}
if (isError) {
return <p>There was an error trying to load the posts.</p>;
}
return (
<div>
{posts.map((post) => (
<PostCard post={post} />
))}
<Pagination page={page} onChangePage={setPage} />
</div>
);
};
export default DirBlogPosts;
Here we have the states page
, posts
, isLoading
and isError
. These states are updated when the component renders for the first time, or whenever the page
is changed.
Can you see the problem here?
- We have all the fetching logic inside our component;
- We need to control many states manually;
- It's hard to create automated tests.
But we can try to follow a different approach and create a cleaner code.
Build your service
First of all, taking advantage of Typescript's features, let's define what is a post:
// src/types/post.ts
export type Post = {
id: number;
title: string;
imageUrl: string;
content: string;
};
The post is basically an object with id
, title
, imageUrl
and content
.
Now we can create the definition of our "list posts service":
// src/services/definitions/list-posts-service.ts
import { Post } from '../../types/post';
export interface ListPostsService {
list(params: ListPostsService.Params): Promise<ListPostsService.Result>;
}
export namespace ListPostsService {
export type Params = {
page?: number;
};
export type Result = Array<Post>;
}
Here we define that the "list post service" implementation should have a method called list
, that will receive the defined params and return the defined result.
Why have we created an interface for that?
The answer is simple: our component will receive this service and execute it. The component doesn't even need to know if you will be using Axios or Fetch. Let's say your component will be agnostic. Sometime later you may need to change the Axios to Fetch, or even use Redux.
So, let's build our Axios service implementation:
// src/services/implementation/axios-list-posts-service.ts
import { AxiosInstance } from 'axios';
import { Post } from '../../types/post';
import { ListPostsService } from '../definitions/list-posts-service';
export default class AxiosListPostsService implements ListPostsService {
constructor(private readonly axiosInstance: AxiosInstance) {}
async list(params: ListPostsService.Params): Promise<ListPostsService.Result> {
const { data } = await this.axiosInstance.get<Array<Post>>('/posts', {
params: { page: params.page },
});
return data;
}
}
This is our implementation using Axios. We need the Axios instance in the constructor, and in the method list
we make the request to our endpoint /posts
.
As we are already working on this service, let's also create a mocked version to use on the tests:
import faker from 'faker';
import lodash from 'lodash';
import { ListPostsService } from './list-posts-service';
export const mockListPostsServicesResult = (): ListPostsService.Result => {
return lodash.range(10).map((id) => ({
id,
title: faker.lorem.words(),
content: faker.lorem.paragraphs(),
imageUrl: faker.internet.url(),
}));
};
export class ListPostsServiceSpy implements ListPostsService {
params: ListPostsService.Params;
result: ListPostsService.Result = mockListPostsServicesResult();
async list(params: ListPostsService.Params): Promise<ListPostsService.Result> {
this.params = params;
return this.result;
}
}
We just need to store in the class the params and a mocked result to test using Jest later. For the mocked data, I like to use the Faker.js library.
Build your clean component
To manage all the loading and error states that we might need, I like to use the library React Query. You can read the documentation to get every detail on how to include it in your project. Basically you only need to add a custom provider wrapping your app, because the React Query also manages caches for the requests.
import { useState } from 'react';
import { useQuery } from 'react-query';
import { ListPostsService } from '../../services/definitions/list-posts-service';
import Pagination from '../Pagination/Pagination';
import PostCard from '../PostCard/PostCard';
type CleanBlogPostsProps = {
listPostsService: ListPostsService;
};
const CleanBlogPosts: React.FC<CleanBlogPostsProps> = ({ listPostsService }) => {
const [page, setPage] = useState<number>(1);
const {
data: posts,
isLoading,
isError,
} = useQuery(['posts', page], () => listPostsService.list({ page }), { initialData: [] });
if (isLoading) {
return <p data-testid="loading-posts">Loading posts...</p>;
}
if (isError) {
return <p data-testid="loading-posts-error">There was an error trying to load the posts.</p>;
}
return (
<div>
{posts!.map((post) => (
<PostCard key={post.id} post={post} />
))}
<Pagination page={page} onChangePage={setPage} />
</div>
);
};
export default CleanBlogPosts;
Do you see now how much cleaner it is? As a result of useQuery
we have all the states that we need: our data, the loading and the error state. You don't need to use the useEffect
for that anymore. The first parameter in useQuery
can be a string or an array. When I include the page
in this array, it means that the query will refetch using this new value (whenever the page changes, like in the useEffect
).
I also added some data-testid
that will be used for testing. So, let's build it!
Build your component test
Our component required the listPostsService
, so let's use the ListPostsServiceSpy
that we created before. Using this we won't make a real HTTP request, because it's a "fake service".
import { render, screen } from '@testing-library/react';
import reactQuery, { UseQueryResult } from 'react-query';
import { ListPostsServiceSpy } from '../../services/definitions/mock-list-posts-service';
import CleanBlogPosts from './CleanBlogPosts';
type SutTypes = {
listPostsServiceSpy: ListPostsServiceSpy;
};
const makeSut = (): SutTypes => {
const listPostsServiceSpy = new ListPostsServiceSpy();
return {
listPostsServiceSpy,
};
};
jest.mock('react-query', () => ({
useQuery: () => {
return {
data: [],
isLoading: false,
isError: false,
};
},
}));
describe('CleanBlogPosts', () => {
it('should show loading state', async () => {
const { listPostsServiceSpy } = makeSut();
jest.spyOn(reactQuery, 'useQuery').mockReturnValueOnce({
data: listPostsServiceSpy.result,
isLoading: true,
isError: false,
} as any);
render(<CleanBlogPosts listPostsService={listPostsServiceSpy} />);
expect(screen.getByTestId('loading-posts')).toBeInTheDocument();
});
it('should show error state', async () => {
const { listPostsServiceSpy } = makeSut();
jest.spyOn(reactQuery, 'useQuery').mockReturnValueOnce({
data: listPostsServiceSpy.result,
isLoading: false,
isError: true,
} as any);
render(<CleanBlogPosts listPostsService={listPostsServiceSpy} />);
expect(screen.getByTestId('loading-posts-error')).toBeInTheDocument();
});
it('should list the posts', async () => {
const { listPostsServiceSpy } = makeSut();
jest.spyOn(reactQuery, 'useQuery').mockReturnValueOnce({
data: listPostsServiceSpy.result,
isLoading: false,
isError: false,
} as UseQueryResult);
render(<CleanBlogPosts listPostsService={listPostsServiceSpy} />);
const posts = await screen.findAllByTestId('post-card');
expect(posts).toHaveLength(listPostsServiceSpy.result.length);
});
});
We added 3 tests:
- loading state: check if our
useQuery
returns the stateisLoading: true
, we will render the loading component. - error state: check if our
useQuery
returns the stateisError: true
, we will render the error component. - success: check if our
useQuery
returns the statedata
, we will render what we want (the list of posts cards). I also checked if we rendered the same amount of posts returned by our service.
Conclusion
This is not "the best solution for your API". Each case might need a different solution. But I hope this helps you to see the alternatives for developing a better code.
Another alternative is to create a custom hook called useListPosts()
that will return the same state as useQuery
, but you also decouple the React Query from your component and use your own implementation in order to create more tests.
Unfortunately, it is not common to see automated tests in front-end code, it is rarely taught in courses. Now open your VSCode and try it :)
Top comments (0)