Learn TanStack - Router Advanced & Data Management
Episode 10 of 24

Learn TanStack - Router Advanced & Data Management

This episode dives deep into TanStack Router: deferred data loading with Await, error boundaries and notFound components, route-based mutations with invalidation, and full-stack routing patterns that use data loaders type-safely.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Episode 7 introduced basic loaders. Real apps are rarely that simple: sometimes part of a page must wait on slow data, sometimes data isn't found, sometimes mutations happen after navigation. Episode 10 addresses all these scenarios.

Episode 10 covers deferred data loading, error boundaries and notFound, route-based mutations, and full-stack routing patterns. Each technique makes the router more resilient at managing heavy, unpredictable data.

By the end of the episode, your routes will know when to wait, when to render partially, when to show an error, and when to show a not-found page — without sacrificing type safety.

Deferred Data Loading

Show Fast Data First, Slow Data Later

A loader can return a promise for slow data, and the page renders first with Await. The fast part appears immediately, the slow part arrives later:

JSDeferred data loading dengan Await
import { Await } from "@tanstack/react-router"
 
const detailRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "detail/$id",
  loader: ({ params }) => ({
    pokok: ambilDetail(params.id),
    rekomendasi: ambilRekomendasi(params.id),
  }),
  component: DetailComponent,
})
 
function DetailComponent() {
  const { pokok, rekomendasi } = useLoaderData({ from: detailRoute.id })
  return (
    <DetailPokok data={pokok} />
    <Await promise={rekomendasi}>
      {(data) => <DaftarRekomendasi data={data} />}
    </Await>
  )
}

useLoaderData returns the object from the loader. rekomendasi is a promise, and <Await promise={rekomendasi}> renders the child component after the promise resolves. The pokok part renders immediately without waiting for recommendations — exactly the pattern used on production product detail pages.

Error Boundaries and NotFound

Handling Errors per Route

The router provides errorComponent for every route, so a loader error doesn't take down the whole app:

JSError dan notFound component
const detailRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "detail/$id",
  loader: async ({ params }) => {
    const data = await ambilDetail(params.id)
    if (!data) throw new NotFoundError()
    return data
  },
  errorComponent: ({ error }) => <p>Terjadi error: {error.message}</p>,
  notFoundComponent: () => <p>Detail tidak ditemukan</p>,
  component: DetailComponent,
})

new NotFoundError() is a special error the router translates into the notFoundComponent page, while other errors fall through to errorComponent. Both can be mounted at the root level as a global fallback or per-route for precise control.

Route-based Mutations

Changing Data After Navigation

Router and Query work together: a mutation runs inside a route, then the router is invalidated so loaders reload the data. For that, queryClient is injected through the router context:

JSMutasi dengan invalidasi router
import { useMutation } from "@tanstack/react-query"
import { useRouter, useRouterContext } from "@tanstack/react-router"
 
function TambahBarang() {
  const router = useRouter()
  const { queryClient } = useRouterContext()
 
  const mutation = useMutation({
    mutationFn: tambahBarang,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["barang"] })
      router.invalidate()
    },
  })
}

useRouterContext() retrieves the queryClient injected when the router was created. After a successful mutation, queryClient.invalidateQueries refreshes the related queries and router.invalidate() re-runs the route loaders that depend on that data — all in one flow.

Full-stack Routing Patterns

Loader as the Single Source of Data

The best full-stack pattern: the loader is the only place data is fetched, and components only read useLoaderData. This ensures navigation always has data before rendering and standardizes how data is prepared:

JSLoader sebagai sumber kebenaran
const daftarRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "daftar",
  loader: async ({ context }) => {
    const data = await context.queryClient.ensureQueryData({
      queryKey: ["daftar"],
      queryFn: ambilDaftar,
    })
    return data
  },
  component: DaftarComponent,
})

ensureQueryData takes data from the cache if it exists, or fetches it if it doesn't — avoiding duplicate requests between the loader and useQuery. With this pattern, components always receive data from the loader and only add useQuery if they need interactivity like refetch.

Tip

ensureQueryData is the best friend of the full-stack pattern: the router guarantees data is available, and the query keeps using the same cache, so there's no duplicate data.

Conclusion

Episode 10 wrapped up data management in the router: deferred loading with Await for slow data, errorComponent and notFoundComponent for failures, route-based mutations that invalidate the router, and the loader as the single source of data with ensureQueryData.

Key takeaways:

  • Loaders can return promises and be rendered with Await.
  • ErrorComponent handles loader failures per route.
  • NotFoundError is translated by the router into a not-found page.
  • queryClient is injected through the router context.
  • router.invalidate re-runs loaders after a mutation.
  • ensureQueryData prevents duplicate requests in loaders and useQuery.

In the next episode, episode 11, we'll discuss virtualization and performance — the basics of windowing and useVirtualizer, virtual scrolling for lists and tables, measuring dynamic items with measureElement, and performance tuning for large data. This is the key to keeping your app smooth with millions of rows!

Learn TanStack - Router Advanced & Data Management | Learn TanStack