Привет хабр!

Недавно на одном из проектов, появилась потребность добавить на страницу фильтры, которые не должны терять данные при перезагрузке страницы и автоматически устанавливать значения при переходе по ссылке. Логичным решением стало использовать параметры 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. Надеюсь статья была полезной для вас.

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