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 useInputwhich 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.

  1. 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 or useGoogleMaps.

  2. Avoid working directly with the DOM.

    Custom hooks should avoid working directly with DOM elements. Instead, use useRef to manipulate DOM elements.

  3. 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.

  4. 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.

  5. Document your code.

    Good documentation helps other developers understand how to use your hook and how it works.

  6. 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 fetchDataThe 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:

  1. 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.

  2. 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 and waitFor 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 fetchInfoOpenLibrarybecause 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.advanceTimersByTimewhich wait 500 milliseconds and check that isLoading equals true. We then use the method waitForto 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 testthat 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 resultbut also the method rerenderwhich 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:

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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *