Привет хабр!
Недавно на одном из проектов, появилась потребность добавить на страницу фильтры, которые не должны терять данные при перезагрузке страницы и автоматически устанавливать значения при переходе по ссылке. Логичным решением стало использовать параметры 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)
Alexandroppolus
28.11.2021 20:11+2В получившемся хуке есть известный антипаттерн "производный стейт". А именно, filter - ни что иное, как значение, которое зависит только от useLocation().search. Использовать это необходимо, только если предполагается менять состояние отдельно от источника: хук useState как бы отправляет его в "самостоятельное плавание". Иногда добавляют useEffect, если в некоторых случаях надо снова подхватить значение из источника или что-то подмержить. Хук в статье ничего этого не делает, просто искусственно поддерживает два одиинаковых значения в локальном стейте и источнике. То есть, как только кто-то "посторонний" поменяет search, то мы об этом не узнаем - ведь filter только при создании компонента берётся из истории.
Правильный подход - просто вычислять на каждом рендере filter из search (если эти вычисления кажутся тяжелыми, то useMemo в помощь). В обоих функциях, которые возвращаются из хука, менять только history (тогде и useCallback на них будет легко навесить).
И нейминг у этих функций поправить, назвать их changeFilter и clearFilter.
Carduelis
В приведенном коде хука все танцы с
useCallback
бесмысленны, так как функцияsetSearchQuery
пересоздается каждый раз. Аfilter
никаким образом не мемоизируется, и будет триггерить пересоздание, так как это объект, а массив изdependencies
работает по принципу строгого сравнения.Как результат, множество лишних рендеров и дополнительный оверхед в виде неработающей мемоизации.
Кстати, совершенно не обязательно добавлять функцию-сеттер из
useState
в массивdependencies
уuseMemo
иuseCallback
.P.S.: Комментарий написал исключительно, чтобы текущее решение не было скопировано в реальный проект один в один.
Roman9131 Автор
Спасибо за замечание, пожалуй с вами соглашусь, поправлю
Roman9131 Автор
Может лучше будет замемоизировать filter и setSearchQuery, подумаю над вариантом
Alexandroppolus
Лучше добавлять. Иначе линтер наругается, придется его накрывать дизаблом, и задизабленный линтер потом не поймает реальную проблему.
justboris
+1 про бесполезные useCallback. Дело в том что коллбек зависит от getFilterQuery, а его вполне могут передавать вот так
В этой ситуации, все эти useCallback оказываются мартышкиным трудом, безо всякой пользы.
Более того, даже в идеальном случае useCallback не покажут никаких улучшений. @Roman9131 у вас есть бенчмарк показывающий пользу useCallback? А если нет, зачем вы чинили то что не сломано?
Alexandroppolus
Понятное дело, что если где-то упоролись в useCallback, то надо проследить всю цепочку.
justboris
Ясное дело, что нужно проследить всю цепочку. Но это приводит к очень хрупкому коду. При этом выгода от всего этого совершенно не понятна
Alexandroppolus
Не уверен, что это называется "хрупкий код". Жизнеспособность его не страдает, если где-то кто-то не поддержал мемоизацию. Это просто дополнительная опция, которая окажется полезной или нет, при этом никак не придется менять хук.
К тому же без useCallback могут быть не только лишние ререндеры, а ещё лишние срабатывания useEffect, что тоже так себе.
Roman9131 Автор
Спасибо за комментарий, useCallback и useMemo(немного поправил получение фильтра) здесь нужны, для того чтобы при каждом ререндере возвращать мемоизированные значения. Иначе, все дочерние компоненты, которые будут их получать в качестве пропсов, будут ререндериться каждый раз. Идея добавление useCallback, как раз была именно в том, чтобы исключить рередер дочерних компонентов. В случае передачи функций таким способом, как вы предложили, действительно будет создаваться каждый раз новая функция, но зачем так делать. Если если нужна оптимизация, то лучше передавать одну и туже ссылку на функцию, путем импортирования например из вне React компонента или мемоизируя ссылку внутри компонента.
justboris
Чтобы что? Вы можете в реальных цифрах показать, что это улучшает?