In the previous chapter, we went through setting up the mocked API, which we will be consuming in our application.
In this chapter, we will be learning how to consume the API via the application.
When we say API, we mean the API backend server. We will learn how to fetch data from both the client and the server. For the HTTP client, we will be using Axios, and for handling fetched data, we will be using the React Query library, which allows us to handle API requests and responses in our React application.
In this chapter, we will cover the following topics:
By the end of this chapter, we will know how to make our application communicate with the API in a clean and organized way.
Before we get started, we need to set up our project. To be able to develop our project, we will need the following things installed on our computer:
There are multiple ways to install Node.js and npm. Here is a great article that goes into more detail: https://www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js.
The code files for this chapter can be found here: https://github.com/PacktPublishing/React-Application-Architecture-for-Production
The repository can be cloned locally with the following command:
git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git
Once the repository has been cloned, we need to install the application’s dependencies:
npm install
We can provide the environment variables using the following command:
cp .env.example .env
Once the dependencies have been installed, we need to select the right stage of the code base that matches this chapter. We can do that by executing the following command:
npm run stage:switch
This command will prompt us with a list of stages for each chapter:
? What stage do you want to switch to? (Use arrow keys) ❯ chapter-02 chapter-03 chapter-03-start chapter-04 chapter-04-start chapter-05 chapter-05-start (Move up and down to reveal more choices)
This is the sixth chapter, so we can select chapter-06-start if we want to follow along, or chapter-06 to see the final results of this chapter.
Once the chapter has been selected, all the files required to follow along with this chapter will appear.
For more information about the setup details, check out the README.md file.
For the API client of our application, we will be using Axios, a very popular library for handling HTTP requests. It is supported in both the browser and the server and has an API for creating instances, intercepting requests and responses, canceling requests, and so on.
Let’s start by creating an instance of Axios, which will include some common things we want to be done on every request.
Create the src/lib/api-client.ts file and add the following:
import Axios from 'axios'; import { API_URL } from '@/config/constants'; export const apiClient = Axios.create({ baseURL: API_URL, headers: { 'Content-Type': 'application/json', }, }); apiClient.interceptors.response.use( (response) => { return response.data; }, (error) => { const message = error.response?.data?.message || error.message; console.error(message); return Promise.reject(error); } );
Here, we have created an Axios instance where we define a common base URL and the headers we want to include in each request.
Then, we attached a response interceptor where we want to extract the data property from the response and return that to our client. We also defined the error interceptor where we want to log the error to the console.
Having an Axios instance configured is, however, not enough to handle requests in React components elegantly. We would still need to handle calling the API, waiting for the data to arrive, and storing it in a state. That’s where React Query comes into play.
React Query is a great library for handling async data and making it available in React components.
The main reason that React Query is a great option for handling the async remote state is the number of things it handles for us.
Imagine the following component, which loads some data from the API and displays it:
const loadData = () => Promise.resolve('data'); const DataComponent = () => { const [data, setData] = useState(); const [error, setError] = useState(); const [isLoading, setIsLoading] = useState(); useEffect(() => { setIsLoading(true); loadData() .then((data) => { setData(data); }) .catch((error) => { setError(error); }) .finally(() => { setIsLoading(false); }); }, []); if (isLoading) return <div>Loading</div>; if (error) return <div>{error}</div>; return <div>{data}</div>; };
This is fine if we fetch data from an API only once, but in most cases, we need to fetch it from many different endpoints. We can see that there is a certain amount of boilerplate here:
That’s where React Query comes in. We can update our component to the following:
import { useQuery } from '@tanstack/react-query'; const loadData = () => Promise.resolve('data'); const DataComponent = () => { const {data, error, isLoading} = useQuery({ queryFn: loadData, queryKey: ['data'] }) if (isLoading) return <div>Loading</div>; if (error) return <div>{error}</div>; return <div>{data}</div>; };
Notice how the state handling is abstracted away from the consumer. We do not need to worry about storing the data, or handling loading and error states; everything is handled by React Query. Another benefit of React Query is its caching mechanism. For every query, we need to provide a corresponding query key that will be used to store the data in the cache.
This also helps with the deduplication of requests. If we called the same query from multiple places, it would make sure the API requests happen only once.
Now, back to our application. We already have react-query installed. We just need to configure it for our application. The configuration needs a query client, which we can create in src/lib/react-query.ts and add the following:
import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, useErrorBoundary: true, }, }, });
React Query comes with a default configuration that we can override during the query client creation. A full list of options can be found in the documentation.
Now that we have created our query client, we must include it in the provider. Let’s head to src/providers/app.tsx and replace the content with the following:
import { ChakraProvider, GlobalStyle, } from '@chakra-ui/react'; import { QueryClientProvider } from '@tanstack/ react-query'; import { ReactQueryDevtools } from '@tanstack/ react-query-devtools'; import { ReactNode } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { theme } from '@/config/theme'; import { queryClient } from '@/lib/react-query'; type AppProviderProps = { children: ReactNode; }; export const AppProvider = ({ children, }: AppProviderProps) => { return ( <ChakraProvider theme={theme}> <ErrorBoundary fallback={<div>Something went wrong!</div>} onError={console.error} > <GlobalStyle /> <QueryClientProvider client={queryClient}> <ReactQueryDevtools initialIsOpen={false} /> {children} </QueryClientProvider> </ErrorBoundary> </ChakraProvider> ); };
Here, we are importing and adding QueryClientProvider, which will make the query client and its configuration available for queries and mutations. Notice how we are passing our query client instance as the client prop.
We are also adding ReactQueryDevtools, which is a widget that allows us to inspect all queries. It only works in development, and that is very useful for debugging.
Now that our react-query setup is in place, we can start implementing the API layer for the features.
The API layer will be defined in the api folder of every feature. An API request can be either a query or a mutation. A query describes requests that only fetch data. A mutation describes an API call that mutates data on the server.
For every API request, we will have a file that includes and exports an API request definition function and a hook for consuming the request inside React. For the request definition functions, we will be using the API client we just created with Axios, and for the hooks, we will be using the hooks from React Query.
We’ll learn how to implement it in action in the following sections.
For the jobs feature, we have three API calls:
Let’s start with the API call that fetches jobs. To define it in our application, let’s create the src/features/jobs/api/get-jobs.ts file and add the following:
import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { Job } from '../types'; type GetJobsOptions = { params: { organizationId: string | undefined; }; }; export const getJobs = ({ params, }: GetJobsOptions): Promise<Job[]> => { return apiClient.get('/jobs', { params, }); }; export const useJobs = ({ params }: GetJobsOptions) => { const { data, isFetching, isFetched } = useQuery({ queryKey: ['jobs', params], queryFn: () => getJobs({ params }), enabled: !!params.organizationId, initialData: [], }); return { data, isLoading: isFetching && !isFetched, }; };
As we can see, there are a few things going on:
Since we will be using it outside the feature, let’s make it available at src/features/jobs/index.ts:
export * from './api/get-jobs';
The get job request should be straightforward. Let’s create the src/features/jobs/api/get-job.ts file and add the following:
import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { Job } from '../types'; type GetJobOptions = { jobId: string; }; export const getJob = ({ jobId, }: GetJobOptions): Promise<Job> => { return apiClient.get(`/jobs/${jobId}`); }; export const useJob = ({ jobId }: GetJobOptions) => { const { data, isLoading } = useQuery({ queryKey: ['jobs', jobId], queryFn: () => getJob({ jobId }), }); return { data, isLoading }; };
As we can see, we are defining and exporting the getJob function and the useJob query, which we will use in a moment.
We want to consume this API request outside the feature, so we have to make it available by re-exporting it from src/features/jobs/index.ts:
export * from './api/get-job';
As we already mentioned, whenever we change something on the server, it should be considered a mutation. With that said, let’s create the src/features/jobs/api/create-job.ts file and add the following:
import { useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { queryClient } from '@/lib/react-query'; import { Job, CreateJobData } from '../types'; type CreateJobOptions = { data: CreateJobData; }; export const createJob = ({ data, }: CreateJobOptions): Promise<Job> => { return apiClient.post(`/jobs`, data); }; type UseCreateJobOptions = { onSuccess?: (job: Job) => void; }; export const useCreateJob = ({ onSuccess, }: UseCreateJobOptions = {}) => { const { mutate: submit, isLoading } = useMutation({ mutationFn: createJob, onSuccess: (job) => { queryClient.invalidateQueries(['jobs']); onSuccess?.(job); }, }); return { submit, isLoading }; };
There are a few things going on here:
We don’t have to export this request from the index.ts file since it is used only within the jobs feature.
For the organizations feature, we have one API call:
Let’s create src/features/organizations/api/get-organization.ts and add the following:
import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { Organization } from '../types'; type GetOrganizationOptions = { organizationId: string; }; export const getOrganization = ({ organizationId, }: GetOrganizationOptions): Promise<Organization> => { return apiClient.get( `/organizations/${organizationId}` ); }; export const useOrganization = ({ organizationId, }: GetOrganizationOptions) => { const { data, isLoading } = useQuery({ queryKey: ['organizations', organizationId], queryFn: () => getOrganization({ organizationId }), }); return { data, isLoading }; };
Here, we are defining a query that will fetch the organization based on the organizationId property we pass.
Since this query will also be used outside the organizations feature, let’s also re-export from src/features/organizations/index.ts:
export * from './api/get-organization';
Now that we have defined all our API requests, we can start consuming them in our application.
To be able to build the UI without the API functionality, we used test data on our pages. Now, we want to replace it with the real queries and mutations that we just made for communicating with the API.
We need to replace a couple of things now.
Let’s open src/pages/organizations/[organizationId]/index.tsx and remove the following:
import { getJobs, getOrganization, } from '@/testing/test-data';
Now, we must load the data from the API. We can do that by importing getJobs and getOrganization from corresponding features. Let’s add the following:
import { JobsList, Job, getJobs } from '@/features/jobs'; import { getOrganization, OrganizationInfo, } from '@/features/organizations';
The new API functions are a bit different, so we need to replace the following code:
const [organization, jobs] = await Promise.all([ getOrganization(organizationId).catch(() => null), getJobs(organizationId).catch(() => [] as Job[]), ]);
We must replace it with the following:
const [organization, jobs] = await Promise.all([ getOrganization({ organizationId }).catch(() => null), getJobs({ params: { organizationId: organizationId, }, }).catch(() => [] as Job[]), ]);
The same process should be repeated for the public job page.
Let’s open src/pages/organizations/[organizationId]/jobs/[jobId].tsx and remove the following:
import { getJob, getOrganization, } from '@/testing/test-data';
Now, let’s import getJob and getOrganization from the corresponding features:
import { getJob, PublicJobInfo } from '@/features/jobs'; import { getOrganization } from '@/features/organizations';
Then, inside getServerSideProps, we need to update the following:
const [organization, job] = await Promise.all([ getOrganization({ organizationId }).catch(() => null), getJob({ jobId }).catch(() => null), ]);
For the dashboard jobs, the only thing we need to do is to update the imports so that we no longer load jobs from test data but from the API.
Let’s import useJobs from the jobs feature instead of the test data by updating the following lines in src/pages/dashboard/jobs/index.tsx:
import { JobsList, useJobs } from '@/features/jobs'; import { useUser } from '@/testing/test-data';
We will still keep useUser from test-data for now; we will replace this in the next chapter.
Since the newly created useJobs hook is a bit different than the test-data one, we need to update the way it is being used, as follows:
const jobs = useJobs({ params: { organizationId: user.data?.organizationId ?? '', }, });
The job details page in the dashboard is also very straightforward.
In src/pages/dashboard/jobs/[jobId].tsx, let’s remove useJob, which was imported from test-data:
import { useJob } from '@/testing/test-data';
Now, let’s import it from the jobs feature:
import { DashboardJobInfo, useJob, } from '@/features/jobs';
Here, we need to update how useJob is consumed:
const job = useJob({ jobId });
For the job creation, we will need to update the form, which, when submitted, will create a new job.
Currently, the form is not functional, so we need to add a couple of things.
Let’s open src/features/jobs/components/create-job-form/create-job-form.tsx and replace the content with the following:
import { Box, Stack } from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; import { Button } from '@/components/button'; import { InputField } from '@/components/form'; import { useCreateJob } from '../../api/create-job'; import { CreateJobData } from '../../types'; export type CreateJobFormProps = { onSuccess: () => void; }; export const CreateJobForm = ({ onSuccess, }: CreateJobFormProps) => { const createJob = useCreateJob({ onSuccess }); const { register, handleSubmit, formState } = useForm<CreateJobData>(); const onSubmit = (data: CreateJobData) => { createJob.submit({ data }); }; return ( <Box w="full"> <Stack as="form" onSubmit={handleSubmit(onSubmit)} w="full" spacing="8" > <InputField label="Position" {...register('position', { required: 'Required', })} error={formState.errors['position']} /> <InputField label="Department" {...register('department', { required: 'Required', })} error={formState.errors['department']} /> <InputField label="Location" {...register('location', { required: 'Required', })} error={formState.errors['location']} /> <InputField type="textarea" label="Info" {...register('info', { required: 'Required', })} error={formState.errors['info']} /> <Button isDisabled={createJob.isLoading} isLoading={createJob.isLoading} type="submit" > Create </Button> </Stack> </Box> ); };
There are a fewthings worth mentioning in this component:
Note
The create job form requires the user to be authenticated. Since we didn’t implement the authentication system yet, you can use the MSW dev tools to authenticate with the test user to try the form submission.
In this chapter, we learned how to make the application communicate with its API. First, we defined an API client that allows us to unify the API requests. Then, we introduced React Query, a library for handling asynchronous states. Using it reduces boilerplate and simplifies the code base significantly.
Finally, we declared the API requests, and then we integrated them into the application.
In the next chapter, we will learn how to create an authentication system for our application where only authenticated users will be able to visit the dashboard.