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.

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.
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:
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.
The router provides errorComponent for every route, so a loader error doesn't take down the whole app:
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.
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:
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.
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:
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.
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:
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!