Когда я искал свою первую работу в качестве Frontend-разработчика, меня часто спрашивали, умею ли я писать кастомные хуки в React. Тогда я только начинал изучать React и только-только запомнил основы, такие как useState и useEffect. Слово «кастомный хук» для меня было новым и сложным. Но теперь, когда я уже более опытный разработчик, знаю, что это значит и как их использовать.

В этой статье я расскажу о лучших практиках при создании кастомных хуков и приведу пример создания кастомного хука для работы с API. Эта информация будет полезна начинающим разработчикам, которые только начинают изучать кастомные хуки в React.

Что такое кастомный хук?

Кастомный хук - это функция, которая выносит логику компонента в отдельную единицу для повторного использования. Основное отличие кастомных хуков от обычных функций заключается в использовании внутри них стандартных хуков React, таких как useState, useEffect и т.д. Кастомные хуки, подобно стандартным, предназначены для использования внутри функциональных компонентов.

Вот пример хука useInput, который содержит логику обновления значения value в теге input. Этот хук позволяет вынести логику в отдельную функцию, что делает код чище и понятнее, и не загромождает его повторениями:

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; 										

Лучшие практики при создании кастомных хуков

При создании кастомных хуков существуют несколько лучших практик, которые помогают сделать ваш код более читаемым, модульным и удобным для использования. В основном, вы должны придерживаться правил и практик, используемых создателями React в отношении своих собственных хуков.

  1. Используйте префикс use в названии хука.

    Кастомный хук должен начинаться с префикса use в своем названии, чтобы отличать его от обычных компонентов. Например, useFetch или useGoogleMaps.

  2. Избегайте прямой работы с DOM.

    Кастомные хуки должны избегать прямой работы с DOM элементами. Вместо этого, используйте useRef для управления элементами DOM.

  3. Не используйте хуки внутри условных операторов.

    При использовании хуков внутри условных операторов может возникнуть проблема с их выполнением. Хуки должны вызываться только на верхнем уровне функционального компонента. Если хуки вызываются внутри условных операторов, они могут не выполниться в нужный момент или выполниться несколько раз. Это правило касается и стандартных хуков.

  4. Возвращайте массив или объект из кастомного хука.

    Возвращайте массив или объект из кастомных хуков, как и в стандартных хуках. Если возвращается одно значение, массив или объект необязательны. Если возвращается два значения, массив будет достаточным. Если возвращается больше двух значений, лучше использовать объект.

  5. Документируйте свой код.

    Хорошая документация помогает другим разработчикам понять, как использовать ваш хук и как он работает.

  6. Тестируйте ваш код.

    Как и документирование, тестами часто жертвуют. Но тестирование помогает гарантировать, что ваш код работает должным образом и не приводит к ошибкам.

Пишем кастомных хук

Давайте напишем кастомный хук для работы с Open Library API. Мы будем использовать useState для управления состоянием, useEffect для загрузки данных и useRef для троттлинга.

Запрос к API

Я вынесу сам запрос к API в отдельную функцию:

/**
* 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;
   }
};

Документация функции написана с помощью JSDoc. Если вы не знакомы с этим инструментом, рекомендую ознакомиться с его правилами, которые можно найти в официальной документации.

Хотя я не приведу здесь примеров тестов для этой функции, я написал их и вы можете ознакомиться с ними в репозитории, который я подготовил специально для этой статьи.

Хук 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 };
};

Давайте разберем код нашего кастомного хука. Во-первых, у него есть документация, которая описывает его функциональность. Кастомный хук создан для работы с Open Library API, он принимает один входящий параметр - поисковой запрос, и возвращает объект с тремя свойствами: books - массив книг, которые подходят под поисковой запрос, isLoading - статус запроса и isError - статус успешного выполнения запроса.

Если кратко взглянуть на сам код, то можно заметить, что мы используем useState для управления состоянием нашего запроса - статуса загрузки, успешности запроса и полученного ответа. Также логика обработки запроса вынесена в отдельную функцию fetchData, которая делает запрос и управляет всеми тремя состояниями. Мы также добавили троттлинг запросов - запрос уходит только через 500 миллисекунд после того, как пользователь ввел последний символ.

Если слово "троттлинг" вам незнакомо, то советую ознакомиться с ним, а также с дебаунсом в данной статье.

Давайте проверим, выполнили ли мы требования по лучшим практикам при написании кастомных хуков:

Практика

Статус

Используйте префикс use в названии хука

Избегайте прямой работы с DOM

Не актуально

Не используйте хуки внутри условных операторов

✅ (можете увидеть это в репозитории)

Возвращайте массив или объект из кастомного хука

Документируйте свой код

Тестируйте ваш код

Нам осталось только написать тесты на наш хук. Тестирование хуков отличается от стандартных тестов, поэтому тут мы остановимся подробнее.

Тестирование кастомных хуков

Сложность, или скорее, особенность тестирования кастомных хуков состоит в том, что вы используете стандартные хуки внутри. Это значит, что для тестирования вам нужно управлять состоянием, что не всегда удобно делать с помощью Jest, но конечно для этого уже есть удобные решения:

  1. Вы можете использовать библиотеку Enzyme. Подход этой библиотеки состоит в том, что вы создадите в целях тестирования контейнер, внутри которого будете вызывать ваш хук, и таким образом будете тестировать его функциональность.

  2. Вы можете использовать библиотеку @testing-library/react, которая также называется React Testing Library. Эта библиотека предлагает тестировать кастомный хук напрямую, используя метод renderHook для его рендера и waitFor для тестирования разных состояний.

Мы будем использовать @testing‑library/react. Давайте начнем с импорта всего необходимого в наш файл с тестами:

import { renderHook, waitFor, act } from '@testing-library/react';
import { useOpenLibrary } from './useOpenLibrary';
import { fetchInfoOpenLibrary } from './utils';

Метод renderHook нужен для рендера тестируемого хука, waitFor нужен для ожидания выполнения определенного условия внутри хука.

Метод act нужен для тестирования компонентов или хуков, которые изменяют свое состояние в процессе выполнения тестов. Он обеспечивает правильный порядок запуска тестов и обработку всех изменений состояния в нужное время, гарантируя корректную работу компонента или хука в тестах. Таким образом, использование метода act помогает убедиться в правильности отображения всех состояний и корректной работе компонента или хука в тестах.

Следующим шагом мы замокаем нашу функцию fetchInfoOpenLibrary, потому что она протестирована отдельно, и нам не нужно тестировать ее функционал здесь:

// Mock function fetchInfoOpenLibrary
jest.mock('./utils', () => ({
 fetchInfoOpenLibrary: jest.fn(),
}));	

Теперь нужно определить что мы будем делать перед каждым тестом и после каждого:

beforeEach(() => {
   fetchInfoOpenLibrary.mockClear();
   jest.useFakeTimers(); // Use fake timers
 });


 afterEach(() => {
   jest.useRealTimers(); // Returning to real timers after each test
 });		

Мы очищаем все замоканные значения для функции fetchInfoOpenLibrary перед каждым тестом и устанавливаем настройку для использования фейковых таймаутов. Это необходимо, потому что наш хук использует функцию setTimeOut. После каждого теста мы отключаем фейковые таймауты.

Первый тест будет проверять начальное состояние хука при его инициализации без передачи поискового запроса.

it('should return initial state', () => {
   const { result } = renderHook(() => useOpenLibrary());
   expect(result.current.books).toEqual([]);
   expect(result.current.isLoading).toBeFalsy();
   expect(result.current.isError).toBeFalsy();
 });	

В метод renderHook мы передаем колбэк-функцию, в которой вызываем наш хук с начальным состоянием без поискового запроса. Затем мы получаем объект и извлекаем свойство result, которое содержит текущее состояние хука в свойстве current. Мы проверяем, что если вызвать хук с пустым запросом, то загрузка не начнется, ошибка будет false, а список книг будет пустой массив.

В следующем тесте мы проверяем успешную загрузку информации по поисковому запросу.

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();
 });

Мы создаем массив книг "books", который будет возвращен запросом, и замокаем ответ функции fetchInfoOpenLibrary. Затем мы вызываем наш хук с тестовым запросом. Для ожидания выполнения запроса мы используем методы act и jest.advanceTimersByTime, которые ожидают 500 миллисекунд и проверяют, что isLoading равен true. Затем мы используем метод waitFor, чтобы дождаться изменения состояния isLoading на false, что означает, что загрузка завершилась. Мы также проверяем, что внутри нашего хука функция fetchInfoOpenLibrary была вызвана один раз с запросом test, что ответ, который она вернула, соответствует нашему замоканному ответу, и что во время запроса не произошло ошибок.

Теперь мы можем проверить случай, когда запрос вызывает ошибку.

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();
 });

Мы замокаем ошибку при выполнении функции fetchInfoOpenLibrary. В остальном тест проходит аналогично предыдущему, за исключением того, что ответ должен быть пустым массивом, а isError равен true.

Кроме того, мы напишем еще один тест для знакомства с различными возможностями метода renderHook. Он проверяет, что если мы очистим поле поискового запроса после ввода одного запроса, то мы получим из хука снова пустой массив, и функция fetchInfoOpenLibrary не будет вызвана:

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();
 });

Здесь я хочу обратить внимание на то, что в метод renderHook можно передавать опции для вызова вашего хука, например, с какими свойствами его вызывать (подробнее в документации). В данном случае мы передаем туда поисковую строку test. Кроме того, этот метод возвращает не только result, но и метод rerender, вызов которого приводит к повторному рендерингу хука с новыми свойствами.

Если хотите поближе познакомиться с библиотекой @testing‑library/react, то можете прочитать мою статью "Как я перестал беспокоиться и полюбил тестирование React-компонентов"

Популярные кастомные хуки

Существует множество кастомных хуков, написанных сообществом разработчиков, которые позволяют упростить и оптимизировать работу с React. Ниже представлен список некоторых популярных кастомных хуков, а также ссылки на их реализации:

  • usePrevious - сохранение предыдущего значения состояния;

  • useDebounce - задержка вызова функции;

  • useThrottle - ограничение частоты вызова функции;

  • useInterval - запуск функции через определенный интервал времени;

  • useOnClickOutside - обработка клика за пределами элемента;

  • useWindowSize - получение текущего размера окна браузера;

  • useAsync - управление асинхронными операциями;

  • useKeyPress - обработка нажатия клавиши;

  • useHover - обработка наведения курсора на элемент;

Важно помнить, что необходимо быть осторожным при использовании кастомных хуков, написанных не вами, поскольку они не являются частью официальной документации React и могут не поддерживаться в будущих версиях библиотеки.

Заключение

Написание кастомных хуков может показаться сложным для начинающих разработчиков. Для этого необходимо хорошо понимать основы работы React и быть знакомым со стандартными хуками. Однако овладение этим инструментом поможет упростить логику компонентов, сократить повторяющийся код и глубже понять устройство стандартных хуков. А самое главное - вы запросто сможете ответить на собеседовании на коварные вопросы про кастомные хуки.

Ссылки


В завершение хочу порекомендовать бесплатный урок от OTUS по теме "TDD + React" .Test-Driven Development (TDD) — одна из техник экстремального программирования, основанная на 3-х шаговом цикле разработки:

  • Пишем тест на функциональность, которую собираемся добавить.

  • Пишем код, чтобы тест прошел.

  • Делаем рефакторинг теста и кода, если нужно.

На уроке вы научитесь писать тесты для react, и разберетесь с тем, что такое TDD.

Комментарии (8)


  1. nullorone
    18.04.2023 17:43

    Импорт act верен?
    Отображает следующую ошибку

    console.error
    Warning: It looks like you're using the wrong act() around your test interactions.
    Be sure to use the matching version of act() corresponding to your renderer:

    // for react-dom:
    import {act} from 'react-dom/test-utils';
    // ...
    act(() => ...);
    
    // for react-test-renderer:
    import TestRenderer from react-test-renderer';
    const {act} = TestRenderer;
    // ...
    act(() => ...);
    


    1. daneelzam Автор
      18.04.2023 17:43

      В статье я импортирую act из “@testing-library/react“

      import { renderHook, waitFor, act } from '@testing-library/react';


  1. detonatorx
    18.04.2023 17:43
    +1

    Хук можно сделать более гибким, если он будет возвращать помимо { books, isLoading, isError } также fetchData. Это позволит запускать fetchData по определенному событию в требуемом компоненте.


    1. daneelzam Автор
      18.04.2023 17:43

      Да, точно! В этом и прелесть кастомных хуков, это очень гибкий инструмент.


    1. sovaz1997
      18.04.2023 17:43

      А лучше тогда useQuery использовать, может?)


  1. Dron007
    18.04.2023 17:43
    +1

    Троттлинг тут не очень хороший - будет просто всё тормозить на полсекунды. Обычно первый запрос идёт сразу, а остальные уже откладываются. Но статья не об этом, собственно, так что нормально. Главное, в коде так не делать.

    Библиотека RTK Query как раз такое и предлагает для get-запросов плюс кеширование запроса ещё. А для мутаций там хук не сразу выполняется а при вызове возвращаемой хуком функции-инициатора, как выше предлагали. Для query тоже можно в таком режиме использовать.


    1. daneelzam Автор
      18.04.2023 17:43

      Правильно ли я понял, что вы предлагаете отправлять запрос как только начался пользовательский ввод, а последующие запросы откладывать?

      Просто в таком случае, если пользователь хочет получить книги по запросу «Толстой», то будет отправлен запрос по первому введенному символу «Т», а остальные символы будут проигнорированы. Тут смысл троттлинга в том, чтобы убедиться, что пользователь ввел все, что планировал.

      UPD

      Еще раз перечитал, понял, что вы предлагаете вызывать хук вручную после того, как все мутации завершены.

      Вообще было бы интересно посмотреть, как это может выглядеть в коде.


      1. Dron007
        18.04.2023 17:43
        +1

        Я не учёл специфику конкретного применения, что запрос тут идёт сразу после каждого обновления поля ввода. В общем случае для API-запросов это не очень хороший троттлинг, ведь этот хук может и при нажатии кнопки какой-то использоваться. На то он и хук. Но для поля ввода, пожалуй, вполне допустима задержка в полсекунды. Хотя обычно в таких случаях делают, или что поиск начинается после ввода 2-3 символов или debounce, а не троттлинг, чтобы слать запрос только когда набрал название полностью и сделал паузу, а не постоянно в процессе набора.