Динамические Breadcrumbs на React, React Router и Apollo GraphQL
- среда, 28 февраля 2024 г. в 00:00:16
Хлебные крошки - важнейшая часть навигации приложения. В классическом исполнении они отражают текущее положение пользователя в иерархии. А отображение названия карточки товара, статьи или любой другой сущности - это уже, как правило, задача компонента отвечающего за отображение самой сущности. Однако, все может оказаться не так просто. По каким-либо причинам дизайнер, заказчик или другая неведомая сила будет категорично настаивать, чтобы название отображалось именно в хлебных крошках.
Как бы то ни было, задача есть и ее нужно закрыть. Поэтому я и расскажу, как я с ней справился, в надежде получить одобрение или более элегантное решение) Погнали!
Я решал эту задачу в React-приложении, в котором используется React Router 6 версии, дизайн-система Ant,а также Apollo GraphQL и TanStack Query для обращения к API.
Для примера приведу простенькую маршрутизацию по коллекциям книг и фигурок, но этого будет достаточно, чтобы погрузиться в контекст.
Сначала схема:
root/
collections/
books/
:id
figurines/
:id
Теперь код:
export const AppRouter = () => {
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path={ROUTES_PATHS.root}
element={<RootPage />}
errorElement={<RouteErrorBoundary />}
>
<Route
path={ROUTES_PATHS.йте}
element={<Breadcrumbs />}
errorElement={<RouteErrorBoundary />}
handle={{
crumb: () => <Link to={ROUTES_PATHS.collections}>{'Коллекции'}</Link>,
}}
>
<Route
path={ROUTES_PATHS.books}
element={<Outlet />}
errorElement={<RouteErrorBoundary />}
handle={{
crumb: () => <Link to={ROUTES_PATHS.books}>{'Фигурки'}</Link>,
}}
>
<Route
path=':id'
element={<BookCard />}
errorElement={<RouteErrorBoundary />}
handle={{
crumb: (id: string) => (
<ApolloDataLink
getLinkText={(response) => response?.data.name ?? 'Имя книги отсутствует'}
to={`${ROUTES_PATHS.books}/${id}`}
useApolloLazyQuery={useGetBookByIdLazyQuery}
payload={id}
/>
),
}}
/>
<Route
path='*'
element={<Navigate to={ROUTES_PATHS.books} />}
/>
</Route>
<Route
path={ROUTES_PATHS.figurines}
element={<Outlet />}
errorElement={<RouteErrorBoundary />}
handle={{
crumb: () => <Link to={ROUTES_PATHS.figurines}>{'Фигурки'}</Link>,
}}
>
<Route
path=':id'
element={<FigureCard />}
errorElement={<RouteErrorBoundary />}
handle={{
crumb: (id: string) => (
<RestDataLink
getLinkText={(response) => response?.data.name ?? 'Имя фигурки отсутствует'}
to={`${ROUTES_PATHS.figurines}/${id}`}
useRestLazyQuery={useGetFigureByIdLazyQuery}
payload={id}
/>
),
}}
/>
<Route
path='*'
element={<Navigate to={ROUTES_PATHS.figurines} />}
/>
</Route>
<Route
path='*'
element={<Navigate to={ROUTES_PATHS.books} />}
/>
</Route>
<Route
path='*'
element={<NotFoundPage />}
/>
</Route>,
),
);
return <RouterProvider router={router} />;
};
Вся магия происходит в поле crumb
объекта handle
. В документации React Router`а я нашел, что через свойство handle
можно создавать методы, которые будут принимать заранее загруженные данные через свойство loader
компонента Route
, но также можно использовать свойство handle
, как некое «хранилище» для конкретного маршрута, которым можно воспользоваться через useMatches.
Давайте посмотрим на примере компонента Breadcrumbs
:
export const Breadcrumbs: React.FC = () => {
const matches = useMatches();
const crumbs = React.useMemo(
() =>
matches
.filter((match) => Boolean((match.handle as { crumb?: CrumbType })?.crumb))
.map((match) => (match.handle as { crumb: CrumbType })?.crumb(match.params.id)),
[matches],
);
const extraBreadcrumbItems = React.useMemo(
() =>
crumbs.map((crumb) => ({
key: uuidv4(),
title: crumb,
})),
[crumbs],
);
const breadcrumbItems = React.useMemo(
() => [
{
key: 'home',
title: (
<Link to='/'>
{'Главная'}
</Link>
),
},
...extraBreadcrumbItems,
],
[extraBreadcrumbItems],
);
return (
<>
<Breadcrumb items={breadcrumbItems} />
<Outlet />
</>
);
};
Явный минус, что не получится типизировать подобное «хранилище». Поэтому нужно позаботиться о проверках, дабы приложение случайно не упало, если вдруг забудем передать «крошку». Также если кому интересно, что из себя представляет компонент Breadcrumb
, то это можно посмотреть в документации antd.
Осталось показать, что из себя представляют ApolloDataLink
и RestDataLink
- компоненты, которые делают сам запрос для получения названия «крошки».
ApolloDataLink
:
interface IApolloDataLink<TData, TVariables extends Record<string, any>> {
getLinkText: (data: TData | undefined) => string;
to: string;
useLazyQuery: () => Apollo.LazyQueryResultTuple<TData, TVariables>;
variables: TVariables;
}
function ApolloDataLink<TData, TVariables extends Record<string, any>>(props: IApolloDataLinkLink<TData, TVariables>) {
const { getLinkText, useLazyQuery, to, variables } = props;
const [getData, { data }] = useLazyQuery();
React.useEffect(() => {
getData({ variables });
}, [variables]); // eslint-disable-line react-hooks/exhaustive-deps
return <Link to={to}>{getLinkText(data)}</Link>;
}
RestDataLink
:
interface IRestDataLink<TData, TPayload> {
getLinkText: (data: TData | undefined) => string;
to: string;
useRestLazyQuery: (
payloadAsKey: TPayload,
) => [call: (payload: TPayload) => void, query: UseQueryResult<TData, TData>];
payload: TPayload;
}
function RestDataLink<TData, TPayload>(props: IRestDataLink<TData, TPayload>) {
const { getLinkText, useRestLazyQuery, to, payload } = props;
const [getData, { data }] = useRestLazyQuery(payload);
React.useEffect(() => {
getData(payload);
}, [payload]); // eslint-disable-line react-hooks/exhaustive-deps
return <Link to={to}>{getLinkText(data)}</Link>;
}
Не спрашивайте, почему я использую сразу и Apollo и TanStack, так как это уже вопрос о переобувании авторов различных backend-сервисов в приложении, которое и породило идею этой статьи.
Но главное, что и Apollo и TanStack имеют кэш. Поэтому при отображении компонента конкретной сущности данные берутся уже из кэша.
Вроде вышло немного, но в свое время я посидел часа три над этой задачей. Надеюсь, что эта статья будет полезной. Счастливого кодинга!
P.S. В TanStack нет ленивых запросов подобных Apollo, но легко сделать, что-то подобное, прочитав вот этот раздел документации.