Этот материал является переводом оригинальной статьи за авторством TkDodo
Выбор роутера, пожалуй, наиболее важное архитектурное решение при проектировании веб-приложения. Роутер — это не просто очередная зависимость в node_modules, ведь он буквально «склеивает» составляющие ПО в единое целое. Роутер обеспечивает качественный UX, упрощая пользователям навигацию в интерфейсе, а также не менее качественный DX, помогающий разработчикам не сходить с ума по мере внедрения новых роутов в свой продукт.
В прошлом году мне выпала задача объединить два приложения, в которых использовались разные роутеры. Мы тщательно оценили каждый из вариантов и один из них нам пришёлся по душе, но во втором были удачные решения, которых мы не обнаружили в первом. По итогу ни один из претендентов не был «тем самым».
К счастью для нас, всего пару месяцев назад TanStack Router обновился до v1, и мы решили, что он должен войти в список кандидатов. Что я могу сказать? Этот роутер включает в себя все лучшие наработки, что нам встречались в ходе тестирования.
В этой статье я кратко пройдусь по основным аспектам TanStack Router, что выделяют его на фоне конкурентов. А в последующих статьях я более подробно рассмотрю каждую из функций отдельно.
Type Safe Routing
Как я уже отметил выше, роутинг — одна из ключевых составляющих веб-приложения. Почему же так вышло, что типизация роутов отошла на второй план и оказалась в таком плачевном состоянии?
- Вот вам хук `useParams` и тип `Record<string, string | undefined>`, а дальше сам решай что с ними делать.
- Вот вам компонент `<Link>`, он заблокирует перезагрузку страницы и выполнит навигацию на стороне клиента. Здорово, да? Но ссылку для этого компонента вам придётся указать самостоятельно. И мы примем любую строку `<Link to={`/issues/${issueId}`}>`. Да, мы не знаем, корректно ли вы указали адрес, это *ваша* ответственность.
Складывается ощущение, будто мы работаем с отголосками прошлого, в котором ещё не существовало TypeScript. Разработка велась на чистом JS, а типы затем добавлялись поверх и оказались не шибко лучше, чем простые `any`. Я думаю, что именно так и сложилась судьба большинства существующих роутеров, и справедливости ради, они реально находятся в менее удачном положении из-за того, что появились на свет до эпохи TypeScript.
TanStack Router насквозь пропитан строгой типизацией. Он создавался специально для TypeScript, поэтому оба решения так идеально сочетаются. Конечно, можно использовать TanStack Router и не объявляя корректные типы вовсе, но зачем, если поддержка TS в роутере настолько хороша? Каждая функция была спроектирована таким образом, чтобы грамотно наследовать типы, обеспечивая полностью безопасный (type-safe) подход в разработке. Это значит, что вам не придётся самостоятельно передавать типы в используемые функции и можно забыть о вездесущих скобках `<>`. Более того TanStack Router подробно рассказывает о возникших ошибках, если что-то пошло не так.
TanStack Router насквозь пропитан строгой типизацией. Он создавался специально для TypeScript, поэтому оба решения так идеально сочетаются. Конечно, можно использовать TanStack Router и не объявляя корректные типы вовсе, но зачем, если поддержка TS в роутере настолько хороша? Каждая функция была спроектирована таким образом, чтобы грамотно наследовать типы, обеспечивая полностью безопасный (type-safe) подход в разработке. Это значит, что вам не придётся самостоятельно передавать типы в используемые функции и можно забыть о вездесущих скобках `<>`. Более того TanStack Router подробно рассказывает о возникших ошибках, если что-то пошло не так.
StrictOrFrom
Естественно, мы хотим сохранить хук `useParams`, так что нужно сделать его безопасным с точки зрения наследования типов. Но как? Это ведь зависит от того, откуда мы обращаемся к хуку, верно? Например, если я нахожусь на `/issues/TSR-23`, можно обратиться к параметру `issueId`, а когда я на `/dashboards/25`, я обращаюсь к `dashboardId`, который тоже является сущностью типа `number`. 🤔
По этой причине у `useParams` в TanStack Router есть отдельное поле для ручной передачи маршрута , из которого происходит обращение к хуку:
По этой причине у `useParams` в TanStack Router есть отдельное поле для ручной передачи маршрута , из которого происходит обращение к хуку:
const { issueId } = useParams({ from: '/issues/$issueId' }) // ^? const issueId: string
Параметр `from` является type-safe-параметром — он включает в себя масштабный `union` всех доступных роутов. Так что ошибиться при вводе данных не получится. Такой подход идеально подходит для компонентов, которые работают с конкретым URL-параметром. Если задействовать аналогичный хук в компоненте, где `issueId` не доступен, то вы столкнетесь с ошибкой `invariant` при попытке обратиться к параметру.
Знаю, сейчас вы думаете — "А как я смогу создавать переиспользуемые компоненты с таким подходом? Я хочу, чтобы у меня был компонент с хуком `useParams`, который можно легко задействовать на нескольких разных роутах и принимать решение о том, как должен вести себя код в зависимости от того, существуют нужные параметры или нет".
И хотя я считаю, что куда чаще разработчики пишут компоненты с хуком `useParams` для конкретного роута, TanStack Router позволяет обойти эту конвенцию. Всего лишь нужно указать свойство `strict: false`:
const params = useParams({ strict: false }) // ^? const params: { // issueId: string | undefined, // dashboardId: number | undefined // }
Такой хук никогда не выдаст ошибку в runtime-среде и то, что вы получаете на выходе — всё ещё имеет лучшую типизацию, чем большинство роутеров, что я встречал. Из-за того, что роутер знает обо всех существующих маршрутах заранее, он может сгенерировать `union` из всех потенциально существующих параметров. И это просто невероятно, честно признаться. 🤯 Даже тот факт, что вам нужно каждый раз вручную выбирать тот или иной подход идёт на пользу, так как делает чтение код базы, основанной на TanStack Router ещё приятнее. Не нужно каждый раз гадать о потенциальных поломках, занимаясь рефакторингом.
Об объекте Route
Вы могли ранее столкнуться с примерами кода, где происходит прямое обращение к `Route.useParams()`, куда не нужно передавать дополнительные параметры. Так же есть getRouteApi, выполняющий ту же функцию в тех участках кода, где нет доступа непосредственно к объекту `Route`.
С точки зрения идеи `Route.useParams` и `useParams` выполняют одну и ту же функцию, с той лишь разницей, что один из вариантов «привязан» к заранее определенному параметру `from`. Если вы поняли суть `StrictOrFrom`, то можно представить, что `Route.useParams()` — то же самое, что `useParams({ from: Route.id })`
Links
Думаю, можно не уточнять, что компонент `Link` здесь ведёт себя так же. Опять же, из-за того, что роутер заранее знает о всех существующих роутах, компонент Link знает, какие роуты существуют и какие параметры в них можно передать.
<Link to="/issues/$issueId" params={{ issueId: 'TSR-25' }}> Go to details </Link>
Здесь вы столкнетесь с очевидной ошибкой типизацией, если не передадите параметр `issueId`. Аналогичная ситуация случится, если вы укажете не существующий адрес или параметр, не являющийся строкой. Это же прекрасно. 😍
Поисковые параметры в качестве стейт-менеджера
Use the platform is a great principle, and nothing is more platform than the address bar in your browser. It's something the user can see. It's something they can copy-paste and share with others. It has automatic undo/redo built-in with back and forward navigations.
«Задействуй платформу» — это отличный принцип. В нашем случае под понятие «платформа» идеально подходит адресная строка браузера. Она у пользователя на виду. Из неё можно копировать данные и вставлять туда новые. В ней даже базовая функция навигации присутствует (кнопки «Назад» и «Вперёд»).
Опять же, URLSearchParams — абсолютно не пригодны для работы с типами. Да, мы не можем заранее выяснить, что внутри, потому что пользователь может менять содержимое поисковой строки по своему желанию. Из этого следует сделать вывод, что мы не можем доверять пользователю и должны валидировать передаваемые данные вручную.
Получается, что поисковые параметры привязаны к роуту и мы должны их валидировать. Так почему бы не валидировать их на уровне роутера?
Я не знаю, почему так не заведено, но TanStack Router делает именно то, что нужно, взяв на себя эту функцию. Обратиться к поисковым параметрам можно при помощи хука `useSearch`, который построен по принципу StrictOrFrom, что мы рассматривали в разделе о параметрах роута.
export const Route = createFileRoute('/issues')({ validateSearch: issuesSchema, })
TanStack Router поддерживает standard schema, так что типы для issueSchema можно описать, используя любую совместимую библиотеку. Можно прописать типы вручную, так как `validateSearch` это просто функция, позволяющая конвертировать `Record<string, unknown>` в любой набор параметров по вашему усмотрению.
Лично мне в последнее время понравилось работать с arktype:
```tsx
import { type } from 'arktype'
const issuesSchema = type({
page: 'number > 0 = 1',
filter: 'string = ""',
})
export const Route = createFileRoute('/issues')({
validateSearch: issuesSchema,
})
```
Готово. Теперь обратившись к `useSearch` вы получите полностью типизированный и валидированный объект. `useNavigate` и `Link` тоже обретут доступ к поисковым параметрам с корректными типами. Роутер возьмёт на себя заботу о парсинге и сериализации данных, даже если вы передали в него вложенные объекты и массивы. А чтобы не было лишних ререндеров, задействуется методика structural sharing, позволяющая избежать создания новых объектов при обновлении данных.
### Селекторы и точечные подписки на параметры
Раз уж зашла речь о необязательных ререндерах, то напомню, что мы слишком мало внимания уделяем тому факту, что любое изменения в адресной строке провоцирует перерисовку всех компонентов, так или иначе связанных с данным в URL. Может, это и не проблема при навигации между какими-нибудь двумя страницами на небольших сайтах, но если речь идёт о вложенных роутах, которые тем или иным образом вносят изменения в url, то ререндеринг всей страницы может быть избыточной тратой ресурсов, пагубно влияющей на пользовательский опыт.
У нас был компонент-таблица, отрисованный на роуте с кучей фильтров, каждый из которых был отражён в url. При клике на строку в таблице, пользователя попадал на под-роут, который открывался в формате диалогового окна.
Открытие этого диалогового окна *всегда* приводило к полной перерисовке всей таблицы, потому что мы использовали хук `useParams` в том самом окне. Понятное дело, таблица ещё и Infinite Query в себе содержала, что приводило к заметному падению производительности при попытке открыть модальное окно из таблицы.
Именно в таких ситуациях разработчики прибегают к работе со стейт-менеджерами в духе redux или zustand Они дают возможность «подписываться» на обновления конкретных данных, чтобы компоненты перерисовывались только при условии, что меняются нужная им информация. Почему же аналогичного механизма не создали для параметров в url?
Вы можете либо сами попытаться решить проблему мемоизации, либо задействовать TanStack Router и его свойство `selectors`:
```tsx
const page = useSearch({
from: '/issues',
select: (search) => search.page,
})
```
Если вы ранее сталкивались с селекторами в стейт-менеджерах (или работали с TanStack Query), то код выше должен выглядеть достаточно знакомо. Селекторы — это механизм, дающий возможность отслеживать изменения в конкретных частях какого-то большого стейта. Селекторы можно использовать в хуках `useParams`, `useSearch`, `useLoaderData` и `useRouterState`. Как по мне, это одна из лучших фишек TanStack Router, выделяющая его на фоне конкурентов. 🙌
File-Based Routing
Декларативный роутинг не работает. Идея о том, что для создания роута ваше приложение должно просто рендерить компонент `<Route>` там, где вздумается, звучит неплохо на первый взгляд, но когда речь заходит о достаточно комплексном приложении с множеством вложенных `<Route>`, становится понятно, что поддерживать этот кошмар слишком тяжело. Далеко не раз я видел в коде нечто подобное:
```tsx
<Route path="settings" element={<Settings />} />
```
А потом выяснялось, что реальный маршрут не `/settings`, а, например, `/organization/settings` или `/user/$id/settings`. Пока не исследуешь весь код до родительского компонента — не узнаешь. И это ужасно.
Ну ладно, можно ведь не разделять эту систему на несколько разных файлов, да? Тогда мы получим следующее:
```tsx
export function App() {
return (
<Routes>
<Route path="organization">
<Route index element={<Organization />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="user">...</Route>
</Routes>
)
}
```
Скорее всего, образуется гигантское дерево вложенных роутов, и это ок, но есть и другая проблема. Чтобы добиться type-safe состояния типов, все роуты должны быть известны заранее, что и делает всю концепцию декларативных роутов несовместимой со строгой типизацией.
В связи с этим переходим к другой концепции — Сode-Based Routing, которая по своей сути является плодом эволюции предыдущего подхода. Если мы и так хотим, чтобы все роуты были доступны в одном месте, то почему бы не вынести их из Реакт-компонентов? Так можно добыть побольше сведений о типах. По такому принципу работает функция createBrowserRoute в React Router и createRouter и TanStack Router.
File-Based Routing использует ту же концепцию, только в качестве «файла конфигурации» выступает файловая система. Я знаю, что эта методика вызывает не мало споров, но мне она нравится. Я считаю, что это наиболее быстрый способ начать строить дерево роутов, а также наиболее быстрый способ обнаруживать в нём баги. Более того, это наиболее простой способ добиться качественно работающего автоматического деления кода. Тем, кому не по душе работать с вложенными директориями, понравится flat routes подход или Virtual File Routes для обеспечения полного контроля над тем, где расположены роут-файлы..
Так или иначе, TanStack Router поддерживает и code-based и file-based подходы, ведь по итогу оно всё code-based. 🤓
Встроенная поддержка Suspense
У меня лично отношения с React Suspense складываются ситуативно, но меня радует, что TanStack Router поддерживает Suspense. По умолчанию каждый роут обёрнут в `<Suspense>` и `<ErrorBoundary>`, поэтому внутри можно спокойно использовать `useSuspenseQuery` и быть уверенным, что данные будут доступны на этапе рендеринга
```tsx
export const Route = createFileRoute('/issues/$issueId')({
loader: ({ context: { queryClient }, params: { issueId } }) => {
void queryClient.prefetchQuery(issueQuery(issueId))},
component: Issues,
})
function Issues() {
const { issueId } = Route.useParams()
const { data } = useSuspenseQuery(issueQuery(issueId))
// ^? const data: Issue
}
```
Это значит, что я могу полностью сконцентрироваться на компонентах и не думать о том что какие-то данные придут как `undefined`. 🎉
И хотя TanStack Router поддерживает Suspense, он пока не очень хорошо работает с React Transitions. Сами процессы навигации обёрнуты в функцию startTransition, но данные хранятся вне Реакта с использованием хука useSyncExternalStore для обеспечения точечного ререндеринга. В связи с этим теряется возможность использовать некоторые новые concurrent-фишки Реакта.
С нетерпением ждём имплементации concurrent stores, чтобы обеспечить поддержку Transitions в будущем.
И ещё много всего
Я не затронул тему Route-контекстов, вложенных роутов, интеграции с Query, Search Middleware, не рассказал как работать с монорепозиториями, как использовать TanStack Start и т.п. Но будьте уверены, каждый аспект TanStack Router вас по-настоящему впечатлит.
Ключевая проблема TanStack Router — если вы хоть раз с ним поработали, перейти на что-то другое будет крайне сложно, так как отличный DX и type-safety подход попросту разбалуют вас. Переход на комбинацию TanStack Router и React Query стал для меня переломным моментом и мне не терпится рассказать вам больше о моём новом стэке.
Спасибо [Tanner](https://bsky.app/profile/tannerlinsley.com), [Manuel](https://bsky.app/profile/manuelschiller.bsky.social), [Sean](https://bsky.app/profile/seancassiere.com) и [Christopher](https://bsky.app/profile/chorobin.bsky.social) — они проделали по-настоящему шикарную работу. Их внимание к деталям ощущается в каждом аспекте роутера.
---
На сегодня всё. Если есть какие-то вопросы, вы можете написать мне в bluesky или оставить комментарий к оригинальному посту.