DEV Community

Elias Júnior
Elias Júnior

Posted on

How to make asynchronous requests to your API in React

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;
Enter fullscreen mode Exit fullscreen mode

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?

  1. We have all the fetching logic inside our component;
  2. We need to control many states manually;
  3. 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;
};
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

We added 3 tests:

  • loading state: check if our useQuery returns the state isLoading: true, we will render the loading component.
  • error state: check if our useQuery returns the state isError: true, we will render the error component.
  • success: check if our useQuery returns the state data, 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)