best practices, use case and test coverage
When I was looking for my first job as a Frontend Developer, I was often asked if I could write custom hooks in React. Back then I was just starting to learn React and just memorized the basics like useState
And useEffect
. The word “custom hook” was new and difficult for me. But now that I’m a more experienced developer, I know what it means and how to use them.
In this article, I will talk about the best practices for creating custom hooks and give an example of creating a custom hook for working with API. This information will be useful for novice developers who are just starting to learn custom hooks in React.
What is a custom hook?
A custom hook is a function that takes the logic of a component into a separate unit for reuse. The main difference between custom hooks and normal functions is the use of standard React hooks inside them, such as useState
, useEffect
etc. Custom hooks, like standard hooks, are meant to be used inside functional components.
Here is an example hook useInput
which contains the logic for updating the value value
in the input tag. This hook allows you to move the logic into a separate function, which makes the code cleaner and clearer, and does not clutter it up with repetition:
import React, { useState } from 'react';
function useInput(initialValue) {
const [value, setValue] = useState(initialValue);
function handleChange(e) {
setValue(e.target.value);
}
return [value, handleChange];
}
export default useInput;
Best practices for creating custom hooks.
When writing custom hooks, there are several best practices that help make your code more readable, modular, and usable. Basically, you should stick to the rules and practices used by the React creators regarding their own hooks.
Use the use prefix in the hook name.
A custom hook should start with a use prefix in its name to distinguish it from normal components. For example,
useFetch
oruseGoogleMaps
.Avoid working directly with the DOM.
Custom hooks should avoid working directly with DOM elements. Instead, use
useRef
to manipulate DOM elements.Don’t use hooks inside conditional statements.
When using hooks inside conditional statements, there may be a problem with their execution. Hooks should only be called at the top level of a functional component. If hooks are called inside conditional statements, they may not be executed at the right time, or they may be executed multiple times. This rule also applies to standard hooks.
Return an array or object from a custom hook.
Return an array or object from custom hooks, just like standard hooks. If a single value is returned, an array or object is optional. If two values are returned, an array will suffice. If more than two values are returned, it’s better to use an object.
Document your code.
Good documentation helps other developers understand how to use your hook and how it works.
Test your code.
Like documentation, tests are often sacrificed. But testing helps ensure that your code works as expected and doesn’t introduce bugs.
We write custom hooks.
Let’s write a custom hook to work with the Open Library API. We will use useState
for state management, useEffect
to download data and useRef
for throttling.
API request.
I will move the API request into a separate function:
/**
* fetchInfoOpenLibrary - a function to query information about books and authors using the Open Library API.
* @param {string} query - search query.
* @returns {Promise<{
* key: 'string', title: 'string', author_name: string[], first_publish_year: number
* }[]>} - a promise with the result of the request.
* @throws {Error} - if the request fails.
* */
export const fetchInfoOpenLibrary = async (query) => {
try {
const response = await fetch(`https://openlibrary.org/search.json?q=${query}`);
const data = await response.json();
return data.docs;
} catch (error) {
throw error;
}
};
The function documentation is written using JSDoc. If you are not familiar with this tool, I recommend that you familiarize yourself with its rules, which can be found in the official documentation.
Although I won’t provide sample tests for this function here, I have written them and you can check them out at repositorieswhich I prepared specifically for this article.
Hook useOpenLibrary.
/**
useOpenLibrary - custom hook for working with the Open Library API.
@param {string} query - search query.
@returns {{
books: {key: 'string', title: 'string', author_name: string[], first_publish_year: number}[],
isLoading: boolean,
isError: boolean
}} - object with the following properties:
books - an array of book objects matching the query.
isLoading - book loading status.
isError - error state when loading books.
*/
export const useOpenLibrary = (query) => {
const [books, setBooks] = useState([]);
const [isLoading, setLoading] = useState(false);
const [isError, setError] = useState(false);
const timeoutRef = useRef(null);
const fetchData = async () => {
setLoading(true);
setError(false);
try {
const result = await fetchInfoOpenLibrary(query)
setBooks(result);
setLoading(false);
} catch (error) {
console.error(error);
setLoading(false);
setError(true);
}
};
useEffect(() => {
if (!query || query.trim() === '') {
setBooks([]);
return;
} else {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
fetchData();
}, 500);
}
return () => {
clearTimeout(timeoutRef.current);
};
}, [query]);
return { books, isLoading, isError };
};
Let’s take a look at our custom hook code. First, it has documentation that describes its functionality. The custom hook is designed to work with the Open Library API, it takes one input parameter – a search query, and returns an object with three properties: books
– an array of books that match the search query, isLoading
– request status and isError
– status of successful execution of the request.
If you take a quick look at the code itself, you will notice that we are using useState
to manage the state of our request – the status of the download, the success of the request, and the response received. Also, the request processing logic is moved to a separate function fetchData
The which makes the request and manages all three states. We also added request throttling – the request only goes away 500 milliseconds after the user has entered the last character.
If the word “throttling” is unfamiliar to you, then we advise you to familiarize yourself with it, as well as with the debounce in this article.
Let’s check if we followed the best practice requirements when writing custom hooks:
Practice | Status |
Use the use prefix in the hook name | ✅ |
Avoid working directly with the DOM | Not relevant |
Don’t use hooks inside conditional statements | ✅ (you can see it in the repository) |
Return an array or object from a custom hook | ✅ |
Document your code | ✅ |
Test your code | ❌ |
We just have to write tests for our hook. Testing hooks is different from standard tests, so we’ll go into more detail here.
Testing custom hooks.
The difficulty, or rather the feature, of testing custom hooks is that you use standard hooks internally. This means that for testing you need to manage the state, which is not always convenient to do with Jest, but of course there are already convenient solutions for this:
You can use the library Enzyme. The approach of this library is that you will create a container for testing purposes, within which you will call your hook, and in this way you will test its functionality.
You can use the library @testing-library/react, which is also called the React Testing Library. This library offers to test a custom hook directly using the method
renderHook
to render it andwaitFor
to test different states.
We will use @testing-library/react. Let’s start by importing everything we need into our test file:
import { renderHook, waitFor, act } from '@testing-library/react';
import { useOpenLibrary } from './useOpenLibrary';
import { fetchInfoOpenLibrary } from './utils';
Method renderHook
needed to render the tested hook, waitFor
needed to wait for a certain condition inside the hook.
Method act
needed to test components or hooks that change their state during the execution of tests. It ensures that the tests are run in the correct order and that all state changes are handled at the right time, ensuring that the component or hook in the tests works correctly. Thus, using the act method helps to make sure that all states are displayed correctly and that the component or hook works correctly in tests.
The next step is to lock our function fetchInfoOpenLibrary
because it is tested separately, and we do not need to test its functionality here:
// Mock function fetchInfoOpenLibrary
jest.mock('./utils', () => ({
fetchInfoOpenLibrary: jest.fn(),
}));
Now we need to define what we will do before each test and after each:
beforeEach(() => {
fetchInfoOpenLibrary.mockClear();
jest.useFakeTimers(); // Use fake timers
});
afterEach(() => {
jest.useRealTimers(); // Returning to real timers after each test
});
We clear all zap values for the function fetchInfoOpenLibrary
before each test and set the setting to use fake timeouts. This is necessary because our hook uses the setTimeOut function. After each test, we disable fake timeouts.
The first test will check the initial state of the hook when it is initialized without passing a search query.
it('should return initial state', () => {
const { result } = renderHook(() => useOpenLibrary());
expect(result.current.books).toEqual([]);
expect(result.current.isLoading).toBeFalsy();
expect(result.current.isError).toBeFalsy();
});
The method renderHook
we pass a callback function where we call our initial state hook without a search query. We then get the object and retrieve the result property, which contains the current state of the hook in the current property. We check that if the hook is called with an empty request, the download will not start, the error will be false, and the list of books will be an empty array.
In the next test, we check the successful loading of information for the search query.
it('should fetch books and update state on query change', async () => {
const books = [
{ key: 'key1', title: 'Title 1', author_name: ['Author name 1'], first_publish_year: 1},
{ key: 'key2', title: 'Title 2', author_name: ['Author name 2'], first_publish_year: 1},
];
fetchInfoOpenLibrary.mockResolvedValue(books);
const { result } = renderHook(() => useOpenLibrary('test'));
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current.isLoading).toBeTruthy();
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(fetchInfoOpenLibrary).toHaveBeenCalledTimes(1);
expect(fetchInfoOpenLibrary).toHaveBeenCalledWith('test');
expect(result.current.books).toEqual(books);
expect(result.current.isError).toBeFalsy();
});
We create an array of books “books” that will be returned by the request, and wrap the function’s response fetchInfoOpenLibrary
. We then call our hook with a test request. To wait for the request to be executed, we use the methods act
And jest.advanceTimersByTime
which wait 500 milliseconds and check that isLoading
equals true
. We then use the method waitFor
to wait for the state change isLoading
on false
, which means that the download has completed. We also check that inside our hook the function fetchInfoOpenLibrary
was called once with a request test
that the response it returned matches our mocked response, and that no errors occurred during the request.
Now we can check the case where the request causes an error.
it('should handle fetch errors', async () => {
fetchInfoOpenLibrary.mockRejectedValue(new Error('Failed to fetch'));
const { result } = renderHook(() => useOpenLibrary('test'));
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current.isLoading).toBeTruthy();
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(fetchInfoOpenLibrary).toHaveBeenCalledTimes(1);
expect(fetchInfoOpenLibrary).toHaveBeenCalledWith('test');
expect(result.current.books).toEqual([]);
expect(result.current.isError).toBeTruthy();
});
We lock the error when executing the function fetchInfoOpenLibrary
. The rest of the test is similar to the previous one, except that the answer must be an empty array, and isError
equals true
.
In addition, we will write another test to get acquainted with the various possibilities of the method. renderHook
. It checks that if we clear the search query field after entering one query, then we will get an empty array from the hook again, and the function fetchInfoOpenLibrary
will not be called:
it('should clear books and not fetch on empty query', async () => {
const { result, rerender } = renderHook((query) => useOpenLibrary(query), {
initialProps: 'test',
});
rerender('');
act(() => {
jest.advanceTimersByTime(500);
});
expect(fetchInfoOpenLibrary).not.toHaveBeenCalled();
expect(result.current.books).toEqual([]);
expect(result.current.isLoading).toBeFalsy();
expect(result.current.isError).toBeFalsy();
});
Here I want to draw attention to the fact that in the method renderHook
you can pass options to call your hook, for example, with what properties to call it (for more details, see documentation). In this case, we pass the search string there test
. Also, this method returns not only result
but also the method rerender
which causes the hook to be re-rendered with the new properties.
If you want to get to know the library better @testing-library/reactthen you can read my article “How I Stopped Worrying and Loved Testing React Components”
Popular custom hooks
There are many custom hooks written by the developer community that make it easier and more efficient to work with React. Below is a list of some popular custom hooks, along with links to their implementations:
usePrevious – saving the previous state value;
useDebounce – function call delay;
use Throttle – limiting the frequency of the function call;
useInterval – launching the function after a certain time interval;
useOnClickOutside – handle click outside the element;
useWindowSize – getting the current size of the browser window;
useAsync – management of asynchronous operations;
useKeyPress – keystroke processing;
use Hover – handling hovering over an element;
It is important to remember that you need to be careful when using custom hooks that are not written by you, as they are not part of the official React documentation and may not be supported in future versions of the library.
Conclusion
Writing custom hooks can seem daunting for beginner developers. To do this, you need to have a good understanding of the basics of how React works and be familiar with standard hooks. However, mastering this tool will help you simplify the logic of components, reduce repetitive code, and gain a deeper understanding of how standard hooks work. And most importantly, you can easily answer tricky questions about custom hooks at the interview.
Links:
In conclusion, I would like to recommend free lesson from OTUS on the topic “TDD + React” .Test-Driven Development (TDD) is one of the extreme programming techniques based on a 3-step development cycle:
We write a test for the functionality that we are going to add.
We write the code so that the test passes.
We do refactoring of the test and code, if necessary.
On lesson you will learn how to write tests for react, and understand what TDD is.