Доки по разработке
This project is maintained by teniryte
Loaders исполняются при первом совпадении маршрута и автоматически переисполняются при изменении params, search, context или пользовательских loaderDeps. Они кешируются на уровне маршрута, поэтому повторные переходы используют уже загруженные данные, пока не истек gcTime.
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: 'invoices/$invoiceId',
loaderDeps: ({ params, search }) => ({
invoiceId: params.invoiceId,
currency: search.currency ?? 'usd',
}),
loader: async ({ deps, context, signal }) => {
const res = await context.api.getInvoice(deps.invoiceId, {
currency: deps.currency,
signal,
})
return res.data
},
onLoadError: ({ error, params, cause }) => {
context.logger.error('invoice loader failed', { params, cause, error })
},
shouldReload: ({ context }) => context.flags.forceReloadInvoices,
gcTime: 5 * 60 * 1000,
component: InvoicePage,
})
loaderDeps позволяет вручную описать зависимости и тем самым контролировать кеш-ключ. Удобно, когда нужно учитывать кусок context (например, tenantId) или вручную минимизировать кол-во перезагрузок.shouldReload можно использовать для адаптивного refetch (например, когда пользователь переключает офлайн/онлайн или меняется feature-flag).onLoadError — быстрый хук для логирования, метрик или кастомного fallback без глобальных boundary.gcTime + staleTime позволяют балансировать между «кешировать всё» и «всегда свежо». staleTime = Infinity превращает loader в SSR/SSG источник данных.preload: true в конфиге маршрута подсказывает Routerу прогревать loader заранее при hover/visible prefetch.const invoice = Route.useLoaderData({
// доступно начиная с v1.38
select: (data) => ({
total: data.total,
customerName: data.customer.name,
}),
structuralSharing: true,
})
// или достать метаданные
const loader = Route.useLoader()
if (loader.status === 'pending') return <Skeleton />
Route.useLoader() возвращает { data, status, error, latestPromise, refetch, invalidate }. Это удобно для:
refetch после локальной мутации без глобальной инвалидации;status === 'pending' && loader.latestPromise !== loader.latestSuccessfullyLoadedPromise).await router.loadRoute('/invoices/$invoiceId', {
search: { currency: 'eur' },
preload: true, // не блокирует навигацию
})
router.invalidateLoader({
loader: invoiceRoute.options.loader,
matchId: invoiceRoute.id,
filters: { params: { invoiceId } },
})
router.loadRoute + router.mount() позволяют реализовать hover/viewport prefetch.router.invalidateLoader точечно переисполняет один loader без traversal дерева.router.state.matches + dehydratedLoaderData, затем router.hydrateData(data) на клиенте.beforeLoad и guardsbeforeLoad работает синхронно/асинхронно до loader. Здесь удобно:
context (например, подгрузить feature-flags);redirect, router.redirect, router.notFound() или кастомные ошибки.beforeLoad: async ({ context, params, location }) => {
await context.auth.ensureSession()
if (!context.auth.hasRole('billing')) {
throw redirect({
to: '/403',
search: { from: location.href, requiredRole: 'billing' },
})
}
}
Лайфхак: возвращайте из beforeLoad объект, который попадёт в loaderContext текущего и дочерних маршрутов, например { featureFlags }, чтобы не гонять один и тот же fetch в каждом слое.
team → project → invoice) и объединяйте данные через Route.useRouteContext() вместо одного «толстого» запроса.context.loaderClient.get({ key }).AbortController и прерывайте дочерние fetch’и при отмене родительского signal.Actions живут рядом с маршрутом и автоматически получают доступ к context, params, submission, signal и parsedBody.
export const Route = createFileRoute('/settings/profile')({
component: ProfileForm,
action: async ({ context, submission, signal }) => {
const payload = submission.value
const result = await context.api.updateProfile(payload, { signal })
if (!result.ok) {
return {
status: 'error',
fieldErrors: result.validation,
message: 'Не удалось обновить профиль',
}
}
return redirect({
to: '/settings/profile',
search: { flash: 'updated' },
})
},
})
const action = Route.useAction({
onEachSuccess: (result, { invalidate, loaderClient }) => {
invalidate() // только текущий маршрут
loaderClient.invalidateLoader({ key: ['user'] })
},
})
const handleSubmit = (formData: FormData) =>
action.submit(formData, {
headers: { 'x-csrf-token': window.csrf },
onSuccess: ({ result }) => toast.success(result.message ?? 'Saved'),
onError: (err) => toast.error(err.message ?? 'Ошибка'),
})
Лайфхаки:
<form> используйте <Form route={Route}>, тогда отправка автоматически вызывает action.submit.Route.useActionState() даёт { status, submission, error, isPending } — удобно для disable кнопок или optimistic UI.json({ ... }, { status: 422 }) и ловить это в error boundary, либо обработать в компоненте через action.state.error.router.invalidate() — полная перезагрузка дерева, используйте редко.router.invalidateRoute({ to: '/invoices/$invoiceId', params }) — инвалидация ветки и всех потомков.Route.invalidate() / Route.invalidateLoader() — короткие алиасы в fileRoute.router.invalidateLoader({ loader, matchId, filters }) — точечное перезапуск конкретного loader’а.loaderClient.setLoaderData({ key, updater }) после мутации, чтобы избежать лишнего HTTP-запроса.defer позволяет вернуть микс синхронных и отложенных частей. Это работает и на сервере, и на клиенте.
loader: async ({ context }) =>
defer({
hero: context.api.getHero(), // ждём
stats: context.api.getStats(), // промис пойдёт в поток
suggestions: context.api.getSuggestions(), // можно завернуть в timeout
})
const data = Route.useLoaderData()
return (
<>
<Hero {...data.hero} />
<Suspense fallback={<StatsSkeleton />}>
<Await promise={data.stats}>{(stats) => <Stats data={stats} />}</Await>
</Suspense>
<Suspense fallback={null}>
<Await promise={data.suggestions} catch={(err) => <ErrorBox err={err} />}>
{(items) => <Suggestions items={items} />}
</Await>
</Suspense>
</>
)
Советы:
withTimeout(promise, 2000) и бросайте deferError, чтобы не блокировать весь loader.Стандартный паттерн — использовать loader для гарантии данных, а в компоненте useQuery только для чтения/рефетча.
export const Route = createFileRoute('/invoices/$invoiceId')({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData({
queryKey: ['invoice', params.invoiceId],
queryFn: ({ signal }) => context.api.getInvoice(params.invoiceId, { signal }),
staleTime: 60_000,
}),
component: InvoicePage,
})
const { params } = Route.useRouteContext()
const invoiceQuery = useQuery({
queryKey: ['invoice', params.invoiceId],
enabled: false, // данные уже прогреты
})
await queryClient.invalidateQueries({ queryKey: ['invoice', id] }) внутри onSuccess, а затем Route.invalidateLoader() чтобы выровнять локальный кеш Router.router.dehydrate() включает данные обеих систем.throw router.notFound() для 404 вместо new Error, чтобы триггерить notFoundComponent.throw redirect({ ... }) прямо из loader/action — Router остановит обработку и поменяет маршрут.loader: try/catch и возвращайте { status: 'error', message } — тогда UI решает, что показать.maxAge/staleWhileLoading в loaderClientOptions для повторных попыток без дёргания UI.router.subscribe('onLoadError', (event) => ...) для глобального мониторинга.router.options.defaultPendingComponent помогает увидеть все точки ожидания.router.state.matches содержит актуальные loaderData — удобно логировать в DevTools.debug: true для createRouter, чтобы видеть в консоли граф переисполнений.signal и уважают отмену.Route.useLoaderData/useLoader.beforeLoad, а не внутри компонентов.action или Query приводят к invalidate/setLoaderData, чтобы данные не протухали.<Suspense> + <Await>.router.loadRoute, preload: true) настроен на ключевые маршруты.onLoadError, retry в API, user-friendly fallback.