Привет хабр!

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

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

Раз уж так сейчас стало популярным использовать и писать свои hooks, давайте попробуем написать свой кастомный hook, который будет удовлетворять этим требованиям. Но сначала немного разберемся с понятиями.

Параметры в URL — это последовательность символов, расположенных после адреса. Для их отделения от основного URL используется знак вопроса. Каждый параметр представляет собой пару ключ=значение. Для отделения пар друг от друга используется знак «&».

Например у нас есть страница со списком сотрудников, в которой есть query параметры.

https://example-web-page/employees?name=Bob&surname=Jordan&position=engineer

В процессе написания hook будем использовать React, React Router, TypeScript

Начнем с простого, опишем наш фильтр.

type Filter = {
	name?: string;
	surname?: string;
	position?: string;
};

Далее опишем функцию, которая будет из фильтра делать query строку

const getQueryStringFromObject = (filter: Filter) => {
  return new URLSearchParams(filter).toString();
};

URLSearchParams не поддерживается старыми браузерами и IE. Если требуется поддержка, то используйте методы stringify и parse из query-string, подробнее о нем можно узнать здесь

И наоборот из query строки будем делать фильтр.

export const getObjectFromQueryString = (search: string) => {
  const paramsEntries = new URLSearchParams(search).entries();
  
  return Object.fromEntries(paramsEntries);
};

Далее будем использовать hooks, которые нам предоставляет React Router: useHistory, useLocation. Подробнее о них можно почитать в документации

Начнем собирать наш кастомный hook

export function useFiltersQuery() {
  const { search } = useLocation();
  const filter = getObjectFromQueryString(search);

  return [filter];
}

Итак, при открытии страницы из URL мы будем получать значения фильтров. Теперь нам нужно, чтобы при изменении фильтра у нас менялись значения query параметров. Напишем функцию, которая будет устанавливать новое значение.

  const setSearchQuery = (filter: Filter) => {
  	const search = getQueryStringFromObject(filter);

    history.replace({ search });
  };

Использование replace или push зависит от требований, если вы хотите записывать в историю все изменения фильтра то можно использовать push, в моем случае достаточно записать только последнее. Подробнее о методах можно посмотреть в документации React Router

Теперь напишем функции, которые будем передавать в качестве callback function из нашего кастомного hook. Нам потребуется функция для изменения фильтра

const сhangeFilter = (fieldName: string) => (value: string) => {
   const newFilter = { ...filter, [fieldName]: value };
 
   setQueryParams(newFilter);
};

Функция для удаления значения из фильтра

const сlearFilter = (fieldName: string) => () => {
   const newFilter = omit(filter, fieldName);

   setQueryParams(newFilter);
};

В качестве вспомогательной функции, использовали omit из lodash, которая будет удалять свойство из объекта

Cоберём всё это в наш кастомный hook, но перед этим сделаем несколько изменений для того, чтобы он стал универсальным для разных фильтров.

  • Добавим типизацию в нашем кастомный hook для передачи правильных типов в компоненты.

  • Добавим, в качестве необязательных параметров, две функции для обработки фильтра и query строки для более сложных фильтров

  • Обернем функции в useCallback для мемоизации ссылок и useMemo для мемоизации фильтра

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

type useFilterQueryTypes<T> = [
  T,
  (fieldName: string) => (value: string) => void,
  (fieldName: string) => () => void
];

И сам кастомных hook

export function useFilterQuery<T extends object>(
  getFilterQuery?: (query: string) => T,
  getSearchQuery?: (filter: T) => string
): useFilterQueryTypes<T> {
  const { search } = useLocation();
  const history = useHistory();

	const filter = useMemo(() =>
    // используем функцию переданную через параметры или дефолтную
    (getFilterQuery ? getFilterQuery(search) : getObjectFromQueryString(search)),
    [search, getFilterQuery]
  );

  const setSearchQuery = useCallback((filter: T) => {
    	// используем функцию переданную через параметр или дефолтную
      const search = getSearchQuery 
      	? getSearchQuery(filter) 
      	: getQueryStringFromObject(filter).toString();

      history.replace({ search });
   },
   	[history, getSearchQuery]
  );

  const сhangeFilter = useCallback((fieldName: string) => (value: string) => {
      const newFilter = { ...filter, [fieldName]: value };

      setSearchQuery(newFilter);
   },
    [filter, setSearchQuery]
  );

  const сlearFilter = useCallback((fieldName: string) => () => {
      const newFilter = omit(filter, fieldName);

      setSearchQuery(newFilter);
	 },
    [filter, setSearchQuery]
  );
	// возвращаем сам фильтр и две функции для его изменения
  return [filter, сhangeFilter, сlearFilter];
}

Функции которые передаются в качестве параметров в useFiltersQuery, можно было не передавать в данном примере, но для более сложных фильтров это потребуется сделать.

Пример более сложного фильтра

Например если нам потребуется сделать из одного query параметра несколько фильтров, или как в примере ниже, получить данные по идентификатору. В этом варианте для примера используем методы stringify и parse из query-string для случая когда требуется поддержка старых браузеров.

type Contact = {
  id: string;
  phone: number;
  email: string;
};

type Filter = {
  phone: number;
  email: string;
};

const getContactsFilter = (contacts : Contact[]) => (search: string) => {
  const { id } = qs.parse(search);
  const { phone, email } = contacts.find(contact => contact.id === id)
  	|| {};


  return {
    phone,
    email,
  };
};

const getSearchQuery = (filter: Filters) => {
  const { phone, email } = filter;
  const contactId = someExampleSearchFunction(phone, email)

  return qs.stringify(contactId);
};

Посмотрим как его можно вызывать из компонента

const Employees: React.FC = ({ list }: Props) => {
  const [filter, сhangeFilter, сlearFilter] = useFiltersQuery<Filters>();

  return (
    <>
      <EmployesFilters filter={filter} onChangeFilter={onChangeFilter} onClearFilter={onClearFilter} />
      <EmployeesList list={list} filter={filter} />
    </>
  );
};

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

Пример вызовов функций кастомного hook из самого фильтра, это может быть как dropdown так и input для поиска

Пример вызовов функций кастомного hook из самого фильтра

Это может быть как dropdown так и input для поиска, так и другие варианты

<Filter
 selectedItem={filter.name}
 items={names}
 onChange={onChangeFilter("name")}
 onClear={onClearFilter("name")}
/>
<SearchInput
  value={filter.position}
  onChange={onChangeFilter("position")}
  onClear={onClearFilter("position")}
/>

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

  • Начало функции должно начинаться со слова use, это говорит о том, что это hook.

  • Выполнять hooks следует в самом верху иерархии функционального компонента React (нельзя вызывать hooks в условиях и циклах)

  • Вызывать hooks можно только в React функциях или функциональных компонентах React или вызывать hooks из кастомных hooks (как сделано в нашем примере)

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

Удачного кодинга, друзья! Всем Пока

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


  1. Carduelis
    28.11.2021 19:37
    +3

    В приведенном коде хука все танцы с useCallback бесмысленны, так как функция setSearchQuery пересоздается каждый раз. А filter никаким образом не мемоизируется, и будет триггерить пересоздание, так как это объект, а массив из dependencies работает по принципу строгого сравнения.

    Как результат, множество лишних рендеров и дополнительный оверхед в виде неработающей мемоизации.

    Кстати, совершенно не обязательно добавлять функцию-сеттер из useState в массив dependencies у useMemo и useCallback.

    P.S.: Комментарий написал исключительно, чтобы текущее решение не было скопировано в реальный проект один в один.


    1. Roman9131 Автор
      28.11.2021 19:46
      -1

      Спасибо за замечание, пожалуй с вами соглашусь, поправлю


      1. Roman9131 Автор
        29.11.2021 04:28

        Может лучше будет замемоизировать filter и setSearchQuery, подумаю над вариантом


    1. Alexandroppolus
      28.11.2021 20:15

      Кстати, совершенно не обязательно добавлять функцию-сеттер из useState в массив dependencies у useMemo и useCallback.

      Лучше добавлять. Иначе линтер наругается, придется его накрывать дизаблом, и задизабленный линтер потом не поймает реальную проблему.


    1. justboris
      29.11.2021 02:47

      +1 про бесполезные useCallback. Дело в том что коллбек зависит от getFilterQuery, а его вполне могут передавать вот так

      useFiltersQuery(filter => /* do something */)

      В этой ситуации, все эти useCallback оказываются мартышкиным трудом, безо всякой пользы.

      Более того, даже в идеальном случае useCallback не покажут никаких улучшений. @Roman9131 у вас есть бенчмарк показывающий пользу useCallback? А если нет, зачем вы чинили то что не сломано?


      1. Alexandroppolus
        29.11.2021 08:53

        Дело в том что коллбек зависит от getFilterQuery, а его вполне могут передавать вот так

        Понятное дело, что если где-то упоролись в useCallback, то надо проследить всю цепочку.


        1. justboris
          30.11.2021 12:55

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


          1. Alexandroppolus
            30.11.2021 13:31

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

            К тому же без useCallback могут быть не только лишние ререндеры, а ещё лишние срабатывания useEffect, что тоже так себе.


      1. Roman9131 Автор
        29.11.2021 10:25

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


        1. justboris
          30.11.2021 12:36

          Идея добавление useCallback, как раз была именно в том, чтобы исключить рередер дочерних компонентов

          Чтобы что? Вы можете в реальных цифрах показать, что это улучшает?


  1. Alexandroppolus
    28.11.2021 20:11
    +2

    В получившемся хуке есть известный антипаттерн "производный стейт". А именно, filter - ни что иное, как значение, которое зависит только от useLocation().search. Использовать это необходимо, только если предполагается менять состояние отдельно от источника: хук useState как бы отправляет его в "самостоятельное плавание". Иногда добавляют useEffect, если в некоторых случаях надо снова подхватить значение из источника или что-то подмержить. Хук в статье ничего этого не делает, просто искусственно поддерживает два одиинаковых значения в локальном стейте и источнике. То есть, как только кто-то "посторонний" поменяет search, то мы об этом не узнаем - ведь filter только при создании компонента берётся из истории.

    Правильный подход - просто вычислять на каждом рендере filter из search (если эти вычисления кажутся тяжелыми, то useMemo в помощь). В обоих функциях, которые возвращаются из хука, менять только history (тогде и useCallback на них будет легко навесить).

    И нейминг у этих функций поправить, назвать их changeFilter и clearFilter.


    1. Roman9131 Автор
      29.11.2021 04:38

      Спасибо за замечание, поправлю в статье вычисление filter