React Deep Dive: TanStack Router and TanStack Query - Part 2
TanStack Router and TanStack Query bring a breath of fresh air to React applications. Examples show their use for display, data storage and more.
(Image: erzeugt mit KI durch iX)
- Nils Hartmann
The first part of the article series showed the basics of TanStack Router and Query. In this second and final part, we will look at features that build on this and that applications can use to optimize their display, store data and use the URL to store global information.
Prioritizing displays with Suspense
The detailed view of a task is characterized by two technical features. Firstly, the path to the route contains a variable placeholder that must be filled with a task ID at runtime (/task/1, /task/2 etc.). Secondly, data must be read from two different end points to display the task completely.
(Image:Â Nils Hartmann)
Firstly, the route for the detailed display: This contains a placeholder ("taskId"), as the ID of the task to be displayed is not known at development time, but only at runtime when the route is called. For this reason, the name of the route file must begin with a $ character, for example tasks/$taskId.tsx. The generated TypeScript types for this route ensure that the value for the placeholder is specified correctly when creating a link to this route. The same applies to accessing the value from the URL at runtime.
Loading the data for the detailed display can basically be done in the same way as loading the list with the tasks. However, the suspension feature of React will be demonstrated here as an example, with the interaction of TanStack Router and TanStack Query.
In order to start loading the data for a route as early as possible, the data can already be loaded in the loader method of a route. This method can be specified in the route configuration. If it is set, the router calls it before the route is rendered to determine data for the route. The way in which it does this and whether it works synchronously or asynchronously is up to the method. Components can then access the data via the useLoaderData hook.
However, the use of hook functions such as useQuery is not permitted within the loader method. This is why you work directly with the QueryClient here. As this is a global object, it can be exported from a module and imported into the respective route files. Alternatively, the router offers its own Context, which can be filled with any data, for example the QueryClient, when the application is started. The router transfers the Context object and an object with all path parameters (in this case consisting of "taskId") to the loader function.
Videos by heise
The listing shows a “Hello World” implementation of the loader method for the $taskId route, which demonstrates the basic behavior. The loader method uses the Context to access the QueryClient object and uses it to load the data for the task with the taskId from the URL. Similar to useQuery, ensureQueryData returns the requested data from the cache. If it is not available there, TanStack Query calls the specified query function.
// /src/routes/tasks/$taskId.tsx
export const Route = createFileRoute("/tasks/$taskId")({
component: RouteComponent,
pendingComponent: () => <H3>Loading Task Details...</H3>,
async loader({ context, params }) {
const taskId = params.taskId;
return context.queryClient.ensureQueryData({
queryKey: [“tasks”, “details”, taskId ],
queryFn() { return fetch(“...”)....; }
});
}
});
function RouteComponent() {
const task = Route.useLoaderData();
return <TaskDetails task={task} />
}
The route component accesses the read data to display the task. The TypeScript type for the return value is automatically derived from the return value of the loader method, so that the correct use of the data is also ensured here.
In this example, the router waits to render the RouteComponent until the promise returned by loader has been resolved. The "pendingComponent" component provides visual feedback during the waiting time.
In the example application, however, the route does not require one query, but two. This is because there are so-called insights for each task. These are additions or notes that other users can attach to a task. The backend provides the insights via a separate endpoint so that the frontend must now also access this endpoint when displaying the task. In principle, it is no problem to adapt the loader method and also execute a second request there. The router would then wait for the result of both requests before rendering the route component. However, rendering should already take place as soon as the data for the task details is available – because this is the most important information on the page, which should appear as quickly as possible and already provide added value for users even without the insights.
If the request for the Insights takes longer than reading the task details, a placeholder should be displayed in the component until the Insights are loaded. Conversely, however, the Insights should never appear before the task details, as this would be confusing. (This form of prioritization could also be used in a shopping application, for example, if a product is to be displayed before the ratings for the product are available).
In order to start loading the (potentially slow) data from both endpoints as early as possible, the Loader function uses two ensureQueryData calls, but does not wait for their results and does not return anything. From the router's point of view, it can therefore start rendering the component immediately. The next listing shows the revised Loader function and the route component, which now renders the task details and the insights.
// /src/routes/tasks/$taskId.tsx
import { Suspense } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
// ...
export const Route = createFileRoute("/tasks/$taskId")({
component: RouteComponent,
pendingComponent: () => <H3>Loading Task</H3>,
loader({ context, params }) {
const taskId = params.taskId;
// Starten der beiden Requests parallel, ohne auf das
// Ergebnis zu warten
context.queryClient.ensureQueryData({
queryKey: ["tasks", "details", taskId],
queryFn() { /* ... */ },
});
context.queryClient.ensureQueryData({
queryKey: ["tasks", "details", taskId, "insights"],
queryFn(): { /* ... */ }
}
});
function RouteComponent() {
const taskId = Route.useParams();
return (
<>
<TaskDetails taskId={taskId} />
<Suspense fallback={<H3>Loading Insights...</H3>}>
<InsightList taskId={taskId} />
</Suspense>
</>
);
}
function TaskDetails({ taskId }) {
const task = useSuspenseQuery({
queryKey: ["tasks", "details", taskId],
queryFn() { /* ... */ },
});
return /* Task-Daten darstellen */;
}
function InsightList({ taskId }) {
const insights = useSuspenseQuery({
queryKey: ["tasks", "details", taskId, "insights"],
queryFn() { /* ... */ },
});
return /* Insight-Daten darstellen */
}
The TaskDetails component uses useSuspenseQuery to retrieve the data from the request or from the cache. As both the Loader function and the TaskDetails component use the same query key, TanStack Query ensures that only one server request runs or that the data is retrieved from the cache if it is already available there under the key. In this case, the TaskDetails component can be rendered to completion.
However, if the data is not yet available, React interrupts the rendering of the component at this point and instead searches for a so-called Suspense Boundary in the component hierarchy above. This component can output a placeholder as long as the rendering of the component tree is interrupted. This behavior is roughly comparable to an error thrown by a function: the error is passed up the call stack until a try...catch handler is found. In the case of React, the search is not for a try-catch handler, but for the React Suspense component. However, the route component that renders the TaskDetails component does not use a suspend component. The next highest suspense component in the component hierarchy is provided by the router. It ensures that the pendingComponent from the route configuration is displayed as a fallback component. As soon as the server request that is still running is completed and the useSuspenseQuery call receives the pending data, React automatically re-renders the TaskDetails component and removes the fallback component from the component hierarchy so that the TaskDetails component now appears.
The route component also renders the InsightsList component, which also performs a useSuspenseQuery call. The same principle applies here: If the data is not yet available, React also interrupts the rendering here and searches for the next higher-level Suspense component. In this case, React finds what it is looking for in the route component, because the InsightList component is enclosed with such a component. In this case, only the InsightList component is replaced by the specified fallback component as long as the Insight data is not yet available. The parallel TaskDetails component still remains visible. The Suspense components can therefore be used to precisely prioritize individual parts of the interface, as developers can specify where rendering should pause if data is still missing.
Back to the Loader function: In the implementation shown, it starts the two requests in parallel to keep the waiting time as short as possible. Without the Loader function, the requests would only start when TaskDetails or InsightList is rendered. In the case of the TaskDetails component, this could possibly be tolerated, as it is rendered almost immediately after the Loader function. However, the Insights query would not be executed in this case until the TaskDetails could be displayed, as the pendingComponent is displayed until then. This would lead to the sequential execution of requests (“request waterfalls”) and thus to a slower application or an unnecessarily delayed display of the InsightList component.
There is another reason to start loading the data in the Loader function. The router is able to execute the Loader functions even before the route is activated. For this purpose, a preload strategy can be defined with the link component. This defines whether and when the data for a route is to be loaded in advance. Possible values are, for example, "intent" or "viewport". In the first case, the loader of a route is already running when the mouse cursor is positioned over the link so that the router assumes that the user will probably click on the link. A link that has the preload strategy "viewport" set causes the data to be preloaded as soon as the link becomes visible in the browser's viewport. The listing shows the Link component, which shows a link to the detailed display of a task and ensures that the loader function is already executed when the cursor is positioned over the link:
function TaskTableRow({ task }) {
return <tr>
<td>
<Link
to={"/tasks/$taskId"}
params={{ taskId: task.id }}
preload={"intent"}
>
{task.title}
</Link>
</td>
{ /* ... */ }
</tr>
}
(Image:Â WD Ashari/Shutterstock.com)
enterJS 2025 will take place on May 7 and 8 in Mannheim. The conference offers a comprehensive look at the JavaScript-supported enterprise world. The focus is not only on the programming languages JavaScript and TypeScript themselves, but also on frameworks and tools, accessibility, practical reports, UI/UX and security.
Highlights from the program:
- Modern React applications with TanStack
- 4 critical anti-patterns in React/TypeScript development
- React: Single-page or full-stack application (workshop, May 6)
Tickets are available at an early bird price in the online store.