React comes with several built in hooks, but sometimes you want to create your own in order to build out logic in one place that can be reused across multiple components. (React Docs)
Creating the Custom Hook
One reason you might consider a custom hook is when you have an application and several different pages that all need to fetch the same list of records. Say you have several pages of your application that need a list of all the participant records. Your backend provides a /participants
endpoint that will return all the participants for you. Once you retrieve the participants then you want to parse the response into a list of Participant
typed records.
On each page that you need the participant list you would need to populate an array of participant records that look like this:
const participants: Participant[]
And the participant type would be defined like this:
interface Participant {
id: number;
first_name: string;
last_name: string;
birthdate?: string;
}
You could do two things whenever you need the participants. On each page that you need the participants list you could add a useEffect hook that, on page load, queries the backend for all the participants. That would look something like this:
const Participants: NextPage = () => {
const [apiData, setApiData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const httpClient = useHttpClient();
const columns = getParticipantColumns()
useEffect(() => {
setIsLoading(true)
httpClient.get('/participants').then(response => {
setApiData(response.data)
setIsLoading(false)
return { participants: apiData, isLoading: isLoading }
})
.catch(error => {
console.log(error)
})
}, []);
return (
<div>
<Box sx={{textAlign:'left', height: 400, width: '100%'}}>
<Typography variant={'h6'}> Participants </Typography>
<div style={{ height: 250, width: '100%' }}>
<DataGrid
rows={participants}
columns={columns}
initialState={{
pagination: {
paginationModel: {
pageSize: 5,
},
},
}}
pageSizeOptions={[5]}
checkboxSelection
disableRowSelectionOnClick
/>
</div>
</Box>
</div>
);
}
In order to have multiple pages querying for participants you would need to copy and paste that useEffect on each page that needed the participant list. Instead, we can create a custom hook that will hold all the logic for retrieving users it will look something like this:
import {useHttpClient} from "./useHttpClient";
import {useEffect, useState} from "react";
interface Participant {
id: number;
first_name: string;
last_name: string;
birthdate?: string;
}
function useParticipants(): { participants: Participant[], isLoading: boolean } {
const [apiData, setApiData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const httpClient = useHttpClient();
useEffect(() => {
setIsLoading(true)
httpClient.get('/participants').then(response => {
setApiData(response.data)
setIsLoading(false)
return { participants: apiData, isLoading: isLoading }
})
.catch(error => {
console.log(error)
})
}, []);
return { participants: apiData, isLoading: isLoading }
}
export default useParticipants;
Now our participants page can be refactored to look something like this:
const Participants: NextPage = () => {
const { participants, isLoading } = useParticipants();
const columns = getParticipantColumns()
return (
<div>
<Box sx={{textAlign:'left', height: 400, width: '100%'}}>
<Typography variant={'h6'}> Participants </Typography>
<div style={{ height: 250, width: '100%' }}>
<DataGrid
rows={participants}
columns={columns}
initialState={{
pagination: {
paginationModel: {
pageSize: 5,
},
},
}}
pageSizeOptions={[5]}
checkboxSelection
disableRowSelectionOnClick
/>
</div>
</Box>
</div>
);
}
export default ParticipantsList;
All we really did here is move that useEffect into a custom named hook but it cleans up that page a lot. Not only that but if you have any components that would require a list of participants you can get the list by using useParticipants() hook. They could be drop downs that exist on separate pages that require a list of all participants.
Testing the Custom Hook
First, I want to explain why you would test a custom hook. In general, I see unit tests as having two major benefits:
Unit tests provider faster feedback as you work towards a working solution
Unit tests will prevent regressions in your applications behavior.
So why would you test your custom hooks specifically? In these examples I am focused on custom hooks used for data retrieval. Many times there is some layer of serialization logic involved in hooks that retrieve data. Your component that uses the data retrieved by this hook will count on the data being structured a certain way and it may rely on certain data elements being present. A unit test at this level will help you get that serialization logic in place in a way you know is correct and it will prevent future changes to the code from doing anything that would modify the result of the hook in some breaking way.
A couple options exist for testing custom hooks. One option is to use React Testing Library’s renderHook
function. The documentation for that can be found here.
This method will render a test component that will call the provided callback. So in our case we would want a callback that calls our useParticipants()
hook. We can use renderHook
with our custom hook like this:
renderHook(() => useParticipants())
To use the renderHook
function we could write a test that looks something like this:
import {waitFor, render, screen, act} from "@testing-library/react";
import {AxiosInstance} from "axios";
import useParticipants from "./useParticipants";
import React from "react";
import {useHttpClient} from "./useHttpClient";
import { renderHook } from '@testing-library/react'
jest.mock("./useHttpClient")
const mockedUseHttpClient = jest.mocked(useHttpClient, true)
describe('useParticipants', () => {
const mockHttpClient = {
get: jest.fn(),
post: jest.fn()
}
beforeEach(async () => {
const expectedParticipantsResponse = {
data: [
{
"id":1,
"first_name":"clayton",
"last_name":"johnson",
"birthdate":"2016-04-16",
}
]
}
mockedUseHttpClient.mockReturnValue(((mockHttpClient as unknown) as AxiosInstance))
mockHttpClient.get.mockReturnValue(Promise.resolve(expectedParticipantsResponse))
mockHttpClient.post.mockReturnValue(expectedParticipantsResponse)
});
it("automatically fetches the given URL when called", async () => {
const { result } = renderHook(() => useParticipants())
await waitFor(() => {
expect(result.current.participants).toEqual([{
"id":1,
"first_name":"clayton",
"last_name":"johnson",
"birthdate":"2016-04-16"
}])
expect(mockHttpClient.get).toHaveBeenCalledWith('/participants')
})
})
});
You’ll notice that in our test we need to waitFor
the result because our hook is taking an async action to retrieve data.
The renderHook
function will return a result object that looks something like this:
{
all: Array<any>
current: any,
error: Error
}
In our test above after we wait for the promise to resolve we get a result that looks something like this:
{
participants: [
{
id: 1,
first_name: 'clayton',
last_name: 'johnson',
birthdate: '2016-04-16'
}
],
isLoading: false
}
We can make assertions on that result based on things we know we will need in our component.
The other option you have is to build your own test component and use the custom hook inside that component.
import {waitFor, render, screen, act} from "@testing-library/react";
import {AxiosInstance} from "axios";
import useParticipants from "./useParticipants";
import React from "react";
import {useHttpClient} from "./useHttpClient";
jest.mock("./useHttpClient")
const mockedUseHttpClient = jest.mocked(useHttpClient, true)
const TestParticipantsComponent = () => {
const {participants, isLoading} = useParticipants()
return (
<>{
isLoading ?
<></> :
<div>
{
participants.map((p) => {
return (
<>
<li>First Name: {p.first_name}</li>
<li>Last Name: {p.last_name}</li>
<li>ID: {p.id}</li>
</>
)
})
}
</div>
}
</>
)
}
describe('useParticipants', () => {
const mockHttpClient = {
get: jest.fn(),
post: jest.fn()
}
beforeEach(async () => {
const expectedParticipantsResponse = {
data: [
{
"id":1,
"first_name":"clayton",
"last_name":"johnson",
"birthdate":"2000-04-16",
}
]
}
mockedUseHttpClient.mockReturnValue(((mockHttpClient as unknown) as AxiosInstance))
mockHttpClient.get.mockReturnValue(Promise.resolve(expectedParticipantsResponse))
mockHttpClient.post.mockReturnValue(expectedParticipantsResponse)
});
it('should return renderable participant data', async () => {
act(() => {
render (
<TestParticipantsComponent/>
)
})
expect(mockHttpClient.get.mock.calls[0][0]).toEqual(`/participants`)
await waitFor(() => {
expect(mockHttpClient.get).toHaveBeenCalledWith('/participants')
expect(screen.getByText(/clayton/)).not.toBeNull()
expect(screen.getByText(/1/)).not.toBeNull()
})
});
});
This test has the benefit of ease of readability. We use the custom hook in a, usually more simplified, way that we expect it to be used in our app. Ultimately, we are testing the same thing with both options. By building and using our own test component in our test we also have the benefit of more simply modeling our we might expect the data returned from the custom hook to be used. Although, with the second implementation because we build a test component we are redoing something that renderHook component does for us already.
I consider which option you choose between the two a matter of preference. Maybe it depends on the level of developers on your team an how comfortable they would feel with something like renderHook
in their tests. Maybe something more verbose would be better!
In either case there is value in implementing custom hooks to clean up your components and avoid duplication. And there is value in test the hooks in a unit test to ensure their implementation is what is desired, develop quickly and iteratively, and prevent regressions in their behavior.
These examples can be found in Github here.