Использование настраиваемых хуков для реализации функции поиска с устранением неполадок.

Мы создали приложение Full Stack React Application на последних двух наших историях. Наше приложение использует create-response-app на стороне клиента и JSON Server на бэкэнде.

Мы выполнили наши критерии приемки и доставили продукт нашему менеджеру по продукту.

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

Они создали новый запрос функции.

Запрос функции

  • Как пользователь, я хочу иметь возможность искать книги по названию.

Реализация

Мы можем начать с рассмотрения нашей текущей реализации.

Как получить список книг? Давайте откроем App.js и посмотрим на наш useEffect крючок.

React.useEffect(() => {
  fetch("http://localhost:3001/books")
    .then(res => res.json())
    .then(result => {
      setBooks(result);
    });
}, []);

Наш хук работает, когда наш компонент установлен.

Это отражает старый componentDidMount метод жизненного цикла из компонентов React на основе классов.

Однако useEffect более мощный, чем старый componentDidMount метод жизненного цикла. Мы можем передать массив зависимостей, и наш эффект будет повторяться всякий раз, когда одно из этих изменений. Мы можем думать об этом как о замене componentDidUpdate метода жизненного цикла.

Подумайте вслух - что, если бы мы добавили в наш хук переменную состояния, содержащую поисковый запрос?

Начнем с создания этой новой переменной состояния. В App.js мы объявляем это так:

const [searchTerm, setSearchTerm] = React.useState("");

Затем мы добавляем на страницу input и label. Когда input изменяется, мы обновляем searchTerm.

<div>
  <div className="field">
    <label htmlFor="search">Search for a book</label>
  </div>
  <div className="field">
    <input
      value={searchTerm}
      onChange={event => setSearchTerm(event.target.value)}
      id="search"
    />
  </div>
</div>

Затем мы модифицируем наш хук useEffect, чтобы использовать searchTerm.

React.useEffect(() => {
  fetch(`http://localhost:3001/books?q=${searchTerm}`)
    .then(res => res.json())
    .then(result => {
      setBooks(result);
    });
}, [searchTerm]);

Большой! Теперь, когда searchTerm изменится, наш хук перезапустится, вызовет наш API и обновит список книг.

Давай попробуем в браузере.

Попробовав, мы замечаем проблему. Мы открываем вкладку сети и видим, что наш API вызывается после каждого нажатия клавиши!

Это не хорошо. Локально с производительностью все в порядке. Но производительность в продакшене пострадает. И что еще более важно - мы забрасываем наш API ненужными запросами.

Оптимизация

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

Есть два возможных решения:

  1. Добавьте кнопку отправки, которую пользователь должен нажать, чтобы запустить поиск
  2. Отбросьте наш ввод, чтобы наш поиск запускался только через определенное количество миллисекунд.

Пойдем со вторым решением.

Создание кастомного хука

Самое замечательное в перехватчиках React заключается в том, что мы можем создавать собственные, инкапсулирующие логику в одном месте. Комбинируя setTimeout с useEffect, мы можем создать наш собственный useDebounce хук.

Скопируйте и вставьте следующее в файл с именем useDebounce.js.

import React from "react";
const useDebounce = (value, delay) => {
  const [debouncedValue, setDebouncedValue] = React.useState(value);
  React.useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
  return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  return debouncedValue;
};
export default useDebounce;

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

Первым параметром является value, а вторым - delay. В нашем случае мы установим value в нашу переменную состояния searchTerm.

Внутри тела функции вы можете видеть, что мы объявляем новую переменную состояния с именем debouncedValue.

Мы также определяем хук useEffect. Этот хук настроен на запуск каждый раз, когда value изменяется.

React.useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]); // updates anytime value or delay changes

Как видим, мы используем setTimeout. Если задержка пройдена без повторного запуска эффекта, то debouncedValue обновляется новым значением.

Наконец, наш хук возвращает debouncedValue.

Попробуем использовать его в App.js.

Сначала мы создаем переменную состояния из результата useDebounce.

const debouncedValue = useDebounce(searchTerm, 500);

Затем мы обновляем наш хук useEffect, чтобы использовать debouncedValue вместо searchTerm.

React.useEffect(() => {
  fetch(`http://localhost:3001/books?q=${debouncedValue}`)
    .then(res => res.json())
    .then(result => {
      setBooks(result);
    });
}, [debouncedValue]);

Большой! Давай попробуем в браузере.

Теперь вроде все работает нормально. Я не заметил никаких проблем с производительностью, и когда я открыл вкладку сети, я увидел, что мы сделали только два вызова API.

Отлично, вроде все работает нормально.

Переходим к тестированию.

Тестирование функции

Начнем с определения нового блока описания в App.test.js.

Теперь, когда мы используем setTimeout, нам нужно использовать другую функцию Jest под названием jest.useFakeTimers();.

Нам также нужно использовать тестовую служебную функцию от React под названием act.

Чтобы подготовить компонент к утверждениям, оберните код, отображающий его и выполняющий обновления, внутри вызова act(). Это приближает ваш тест к тому, как React работает в браузере.

Поскольку обновление нашего поискового запроса обновит наш компонент, нам нужно обернуть вызов jest.advanceTimersByTime в действии. Затем мы можем использовать fetch.mock, чтобы убедиться, что наш поисковый запрос был включен в вызов API.

describe("searching", () => {
  it.only("should be able to search for a book", async () => {
    fetch
      .once(JSON.stringify([harryPotter, greatGatsby]))
      .once(JSON.stringify([greatGatsby]));
    const { getByText, getByLabelText } = render(<App />);
    expect(fetch.mock.calls.length).toEqual(1);
    // after API call is resolved, it should display the book
    await waitForElement(() => getByText("Harry Potter"));
    const searchInput = getByLabelText("Search for a book");
    fireEvent.change(searchInput, {
      target: {
        value: "The Great Gatsby"
      }
    });
    act(() => {
      jest.advanceTimersByTime(500);
    });
    await waitForElement(() => getByText("The Great Gatsby"));
    expect(fetch.mock.calls[1][0]).toEqual(
      "http://localhost:3001/books?q=The Great Gatsby"
    );
    expect(fetch.mock.calls.length).toEqual(2);
  });
});

Идеально! Мы завершили реализацию первого запроса функции нашего приложения. Мы начали с наивного подхода, а затем попутно добавили оптимизацию.

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

Исходный код доступен в следующем репозитории Github.