Dev Highlights

Доки по разработке

This project is maintained by teniryte

Loader API

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,
})

Полезные тонкости

Получение данных в компоненте

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 }. Это удобно для:

Префетч и кеширование

await router.loadRoute('/invoices/$invoiceId', {
  search: { currency: 'eur' },
  preload: true, // не блокирует навигацию
})

router.invalidateLoader({
  loader: invoiceRoute.options.loader,
  matchId: invoiceRoute.id,
  filters: { params: { invoiceId } },
})

beforeLoad и guards

beforeLoad работает синхронно/асинхронно до loader. Здесь удобно:

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 в каждом слое.

Паттерны работы с loader

Actions (мутации)

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 ?? 'Ошибка'),
  })

Лайфхаки:

Инвалидация и refetch

Defer и streaming

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>
  </>
)

Советы:

Интеграция с TanStack Query

Стандартный паттерн — использовать 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, // данные уже прогреты
})

Ошибки и повторные попытки

Отладка и профилирование

Чек-лист