Introduction

Welcome to Chapter 13! So far, we’ve taken a deep dive into individual TanStack libraries, understanding their core principles, features, and optimal use cases. You’ve mastered asynchronous state management with TanStack Query, built powerful data tables with TanStack Table, navigated complex routes with TanStack Router, handled forms gracefully with TanStack Form, managed local state with TanStack Store, and optimized rendering with TanStack Virtual. You’re practically a TanStack wizard!

In this exciting chapter, we’re going to bring all that knowledge together. Our goal is to construct a practical, multi-page data dashboard application. This project will serve as a culmination of your learning, demonstrating how these powerful libraries interoperate seamlessly to create a robust, performant, and delightful user experience. We’ll focus on real-world scenarios, integrating data fetching, display, user input, and navigation into a cohesive application.

By the end of this chapter, you’ll have a solid understanding of how to architect applications using the TanStack ecosystem, manage different types of state effectively, and optimize performance for data-intensive UIs. Get ready to put your skills to the test and build something truly impressive!

Core Concepts: The Dashboard Architecture

Building a data dashboard isn’t just about throwing components onto a page; it’s about thoughtful architecture. Our dashboard will feature multiple views, interactive filters, and display large datasets efficiently. Let’s outline the core architectural concepts we’ll employ.

1. Orchestrating Server State and Client State

A critical distinction in modern frontend development is between server state and client state.

  • Server State (Managed by TanStack Query): This refers to data that resides on a remote server, needs to be fetched, cached, and often synchronized. Examples include user lists, product catalogs, or, in our case, dashboard analytics data. TanStack Query is our go-to for this, handling fetching, caching, invalidation, and background refetching.
  • Client State (Managed by TanStack Form/Store): This is data that exists purely on the client, often related to UI interactions or temporary user input. Examples include the current value of an input field, whether a modal is open, or a selected theme. TanStack Form will manage the state of our filter inputs, while TanStack Store could be used for global UI preferences not tied to the URL.

Our dashboard will leverage Query for all data fetching, while Form will manage the UI state of our filters. Sometimes, filter state might also live in the URL (managed by Router) to allow for shareable links.

2. Dynamic Routing for Dashboard Views

A dashboard often has multiple “pages” or “views” (e.g., an overview, a detailed report, settings). TanStack Router will be used to:

  • Define distinct routes for each section of our dashboard.
  • Manage URL parameters and search parameters for things like report IDs or filter values, making views shareable and bookmarkable.
  • Handle data loading for routes, potentially prefetching data using Query loaders.

3. Interactive Data Display with Filtering and Virtualization

The heart of any dashboard is its data display. We’ll use TanStack Table to:

  • Render tabular data fetched by TanStack Query.
  • Implement features like sorting, filtering, and pagination.
  • Crucially, for performance with large datasets, we’ll integrate TanStack Virtual to render only the visible rows, preventing browser slowdowns.

4. The Integrated Data Flow

Imagine a user interacts with a filter on the dashboard. Here’s how the TanStack ecosystem will handle it:

  1. User Input: The user types into an input field, managed by TanStack Form.
  2. Form Submission/Change: The Form updates its internal state.
  3. URL Update (Optional but Recommended): The Form’s values are pushed to the URL’s search parameters via TanStack Router. This makes the filter state persistent and shareable.
  4. Query Invalidation/Refetch: TanStack Query observes changes in its query keys (which will include the filter values from the URL). It automatically refetches the data from the server.
  5. Table Update: The new data is provided to TanStack Table, which re-renders the filtered, sorted, and potentially virtualized data.

This flow ensures a reactive and efficient user experience. Let’s visualize this core interaction with a Mermaid diagram:

graph TD A[User Interaction: Filter Input] --> B{TanStack Form} B --> C[Update Form State] C --> D{TanStack Router} D --> E[Update URL Search Params] E --> F{TanStack Query} F --> G[Detect Query Key Change] G --> H[Fetch New Data from API] H --> I[Cache & Provide Data] I --> J{TanStack Table} J --> K[Render Filtered/Sorted/Virtualized Data]

Diagram 13.1: Integrated Data Flow with TanStack Libraries

Step-by-Step Implementation

For this project, we’ll assume you have a basic React project set up (e.g., with Vite or Create React App). We’ll focus on adding the TanStack pieces.

1. Setup and Installation

First things first, let’s ensure we have all the necessary TanStack libraries installed.

Open your terminal in your project’s root directory and run:

npm install @tanstack/react-query@5.18.0 @tanstack/react-table@8.11.3 @tanstack/react-router@1.11.0 @tanstack/react-form@0.19.0 @tanstack/react-virtual@3.0.0 @tanstack/router-devtools@1.11.0 @tanstack/query-devtools@5.18.0
# Or using yarn:
# yarn add @tanstack/react-query@5.18.0 @tanstack/react-table@8.11.3 @tanstack/react-router@1.11.0 @tanstack/react-form@0.19.0 @tanstack/react-virtual@3.0.0 @tanstack/router-devtools@1.11.0 @tanstack/query-devtools@5.18.0

Why these versions? As of January 7, 2026, these represent the latest stable major versions for these packages, providing the most up-to-date features and performance improvements. It’s always a good practice to use the latest stable releases unless specific compatibility is required.

2. Basic TanStack Router Setup

Let’s start by defining our application’s routes. We’ll have a main dashboard layout with nested routes for an “Overview” and “Reports” section.

src/routes/__root.tsx: This file defines our root layout and the RouterProvider.

// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '../queryClient'; // We'll create this next

export const Route = createRootRoute({
  component: () => (
    <QueryClientProvider client={queryClient}>
      <div className="p-2 flex gap-2">
        <a href="/dashboard/overview" className="[&.active]:font-bold">Overview</a>{' '}
        <a href="/dashboard/reports" className="[&.active]:font-bold">Reports</a>
      </div>
      <hr />
      <Outlet /> {/* This is where nested routes will render */}
      <TanStackRouterDevtools initialIsOpen={false} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  ),
});

Explanation:

  • createRootRoute: Defines the base route for our application.
  • Outlet: This component from TanStack Router is crucial. It acts as a placeholder where child routes will render their components.
  • QueryClientProvider: We wrap our entire application in this, providing the queryClient instance to all components that will use TanStack Query. We’ll set up queryClient next.
  • TanStackRouterDevtools and ReactQueryDevtools: Essential for debugging! They provide a visual interface to inspect router state and query cache.

src/queryClient.ts: Create a new file for our QueryClient instance.

// src/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient();

Explanation: This is a simple, global instance of QueryClient that will manage all our data fetching and caching.

src/routes/dashboard.tsx: The parent route for our dashboard.

// src/routes/dashboard.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router';

export const Route = createFileRoute('/dashboard')({
  component: () => (
    <div className="p-4">
      <h2>Dashboard Home</h2>
      <p>Welcome to your data dashboard!</p>
      <hr className="my-4" />
      <Outlet /> {/* Nested dashboard routes will render here */}
    </div>
  ),
});

Explanation: This route will render a simple “Dashboard Home” message and then render its children (Overview or Reports) inside its Outlet.

src/routes/dashboard.overview.tsx: Our dashboard overview page.

// src/routes/dashboard.overview.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';

// Mock API function (in a real app, this would fetch from a backend)
const fetchOverviewStats = async () => {
  console.log('Fetching overview stats...');
  await new Promise(resolve => setTimeout(resolve, 800)); // Simulate network delay
  return {
    totalSales: (Math.random() * 100000).toFixed(2),
    newCustomers: Math.floor(Math.random() * 500),
    activeUsers: Math.floor(Math.random() * 2000),
    conversionRate: (Math.random() * 10).toFixed(2) + '%',
    lastUpdated: new Date().toLocaleString(),
  };
};

export const Route = createFileRoute('/dashboard/overview')({
  component: () => {
    const { data, isLoading, error } = useQuery({
      queryKey: ['dashboard', 'overview'],
      queryFn: fetchOverviewStats,
    });

    if (isLoading) return <p>Loading overview data...</p>;
    if (error) return <p>Error loading overview: {error.message}</p>;

    return (
      <div>
        <h3>Overview Stats</h3>
        <div className="grid grid-cols-2 gap-4">
          <p><strong>Total Sales:</strong> ${data?.totalSales}</p>
          <p><strong>New Customers:</strong> {data?.newCustomers}</p>
          <p><strong>Active Users:</strong> {data?.activeUsers}</p>
          <p><strong>Conversion Rate:</strong> {data?.conversionRate}</p>
        </div>
        <p className="text-sm text-gray-500 mt-4">Last Updated: {data?.lastUpdated}</p>
      </div>
    );
  },
});

Explanation:

  • createFileRoute('/dashboard/overview'): Defines a route that will be accessible at /dashboard/overview.
  • fetchOverviewStats: A mock asynchronous function simulating an API call.
  • useQuery: This hook from TanStack Query is used to fetch and manage our overview data.
    • queryKey: ['dashboard', 'overview']: A unique key for this specific query. Query uses this to cache and invalidate data.
    • queryFn: fetchOverviewStats: The function that performs the actual data fetching.
  • We handle isLoading and error states, providing a robust user experience.

src/routes/dashboard.reports.tsx: This will be our main reports page with a table.

// src/routes/dashboard.reports.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  useReactTable,
} from '@tanstack/react-table';
import React, { useState } from 'react';

// Define a type for our report data
type SalesReport = {
  id: string;
  productName: string;
  category: string;
  salesAmount: number;
  quantity: number;
  date: string;
};

// Mock API function for sales reports
const fetchSalesReports = async (): Promise<SalesReport[]> => {
  console.log('Fetching sales reports...');
  await new Promise(resolve => setTimeout(resolve, 1200)); // Simulate network delay
  const data: SalesReport[] = Array.from({ length: 50 }, (_, i) => ({
    id: `rep-${i + 1}`,
    productName: `Product ${String.fromCharCode(65 + (i % 26))}${i}`,
    category: ['Electronics', 'Clothing', 'Books', 'Home Goods'][i % 4],
    salesAmount: parseFloat((Math.random() * 1000 + 50).toFixed(2)),
    quantity: Math.floor(Math.random() * 50) + 1,
    date: new Date(Date.now() - i * 86400000).toISOString().split('T')[0],
  }));
  return data;
};

const columnHelper = createColumnHelper<SalesReport>();

const columns = [
  columnHelper.accessor('productName', {
    header: () => 'Product Name',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('category', {
    header: () => 'Category',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('salesAmount', {
    header: () => 'Sales Amount',
    cell: info => `$${info.getValue().toFixed(2)}`,
    sortingFn: 'alphanumeric', // Example: custom sorting logic if needed, default is fine
  }),
  columnHelper.accessor('quantity', {
    header: 'Quantity',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('date', {
    header: 'Date',
    cell: info => info.getValue(),
  }),
];

export const Route = createFileRoute('/dashboard/reports')({
  component: () => {
    const { data: reports, isLoading, error } = useQuery<SalesReport[]>({
      queryKey: ['dashboard', 'salesReports'],
      queryFn: fetchSalesReports,
    });

    const [sorting, setSorting] = useState([]); // State for TanStack Table sorting

    const table = useReactTable({
      data: reports || [], // Provide an empty array when data is not available
      columns,
      state: {
        sorting,
      },
      onSortingChange: setSorting,
      getCoreRowModel: getCoreRowModel(),
      getSortedRowModel: getSortedRowModel(),
    });

    if (isLoading) return <p>Loading sales reports...</p>;
    if (error) return <p>Error loading reports: {error.message}</p>;

    return (
      <div>
        <h3>Sales Reports</h3>
        <div className="overflow-x-auto">
          <table className="min-w-full divide-y divide-gray-200">
            <thead>
              {table.getHeaderGroups().map(headerGroup => (
                <tr key={headerGroup.id}>
                  {headerGroup.headers.map(header => (
                    <th
                      key={header.id}
                      className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
                      onClick={header.column.getToggleSortingHandler()}
                    >
                      {flexRender(header.column.columnDef.header, header.getContext())}
                      {{
                        asc: ' ๐Ÿ”ผ',
                        desc: ' ๐Ÿ”ฝ',
                      }[header.column.getIsSorted() as string] ?? null}
                    </th>
                  ))}
                </tr>
              ))}
            </thead>
            <tbody className="bg-white divide-y divide-gray-200">
              {table.getRowModel().rows.map(row => (
                <tr key={row.id}>
                  {row.getVisibleCells().map(cell => (
                    <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    );
  },
});

Explanation:

  • fetchSalesReports: Another mock API function, generating 50 rows of sales data.
  • useQuery<SalesReport[]>({ queryKey: ['dashboard', 'salesReports'], queryFn: fetchSalesReports }): Fetches our sales data, similar to the overview.
  • createColumnHelper and columns: Define the structure and rendering of our table columns.
  • useState([]) for sorting: TanStack Table manages its internal state, but we lift the sorting state to React’s useState so we can control and persist it.
  • useReactTable: The core hook for TanStack Table.
    • data: The actual data rows.
    • columns: The column definitions.
    • state: { sorting }: Connects our local sorting state to the table.
    • onSortingChange: setSorting: Updates our local sorting state when the table’s sorting changes.
    • getCoreRowModel() and getSortedRowModel(): Essential model plugins for basic table functionality and sorting.
  • Table Rendering: We iterate through table.getHeaderGroups() to render headers and table.getRowModel().rows to render the table body.
  • header.column.getToggleSortingHandler(): This function, when called on click, will toggle the sorting for that column.
  • flexRender: A utility for rendering cell and header content based on the column definition.

src/main.tsx: Your main entry file to set up the router.

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider, createRouter } from '@tanstack/react-router';

// Import the generated route tree
import { routeTree } from './routeTree.gen'; // This file is generated!

// Create a router instance
const router = createRouter({ routeTree });

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

const rootElement = document.getElementById('root')!;
if (!rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement);
  root.render(
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>,
  );
}

Explanation:

  • routeTree.gen.ts: This file is automatically generated by TanStack Router based on your src/routes files. You typically don’t edit it directly.
  • createRouter({ routeTree }): Instantiates the router with our defined route structure.
  • RouterProvider: The component that makes the router available to our React app.

Run the generator: To make routeTree.gen.ts appear, you need to add a script to your package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "start": "npm run dev",
    "generate:route-types": "npx @tanstack/router-cli generate --config ./tanstack-router.config.json"
  },
  // ... other dependencies
}

And create a tanstack-router.config.json file in your project root:

// tanstack-router.config.json
{
  "$schema": "https://unpkg.com/@tanstack/router-cli@1.11.0/dist/config.json",
  "routesDirectory": "./src/routes",
  "generatedRouteTree": "./src/routeTree.gen.ts"
}

Now, whenever you add or modify a route file, run npm run generate:route-types (or configure your IDE to run it on file save, or add it to your dev script if desired) to update routeTree.gen.ts.

3. Adding Filtering with TanStack Form (and Query)

Let’s enhance our reports page by adding a filter for category and a date range. This will involve TanStack Form for input management and TanStack Router for URL synchronization.

First, update src/routes/dashboard.reports.tsx to include the form and integrate filters.

// src/routes/dashboard.reports.tsx (Updated)
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  useReactTable,
} from '@tanstack/react-table';
import React, { useState, useMemo } from 'react';
import { useForm, FieldApi } from '@tanstack/react-form'; // Import useForm and FieldApi
import { useVirtualizer } from '@tanstack/react-virtual'; // Import useVirtualizer

// Define a type for our report data (same as before)
type SalesReport = {
  id: string;
  productName: string;
  category: string;
  salesAmount: number;
  quantity: number;
  date: string;
};

// Define our search parameters type for the route
type ReportsSearch = {
  category?: string;
  startDate?: string;
  endDate?: string;
};

// Mock API function for sales reports (now accepts filters)
const fetchSalesReports = async (filters: ReportsSearch): Promise<SalesReport[]> => {
  console.log('Fetching sales reports with filters:', filters);
  await new Promise(resolve => setTimeout(resolve, 1200)); // Simulate network delay

  const allData: SalesReport[] = Array.from({ length: 1000 }, (_, i) => ({ // Increased data size for virtualization
    id: `rep-${i + 1}`,
    productName: `Product ${String.fromCharCode(65 + (i % 26))}${i}`,
    category: ['Electronics', 'Clothing', 'Books', 'Home Goods'][i % 4],
    salesAmount: parseFloat((Math.random() * 1000 + 50).toFixed(2)),
    quantity: Math.floor(Math.random() * 50) + 1,
    date: new Date(Date.now() - i * 86400000).toISOString().split('T')[0],
  }));

  return allData.filter(report => {
    let match = true;
    if (filters.category && filters.category !== 'All' && report.category !== filters.category) {
      match = false;
    }
    if (filters.startDate && report.date < filters.startDate) {
      match = false;
    }
    if (filters.endDate && report.date > filters.endDate) {
      match = false;
    }
    return match;
  });
};

const columnHelper = createColumnHelper<SalesReport>();

const columns = [
  columnHelper.accessor('productName', {
    header: () => 'Product Name',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('category', {
    header: () => 'Category',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('salesAmount', {
    header: () => 'Sales Amount',
    cell: info => `$${info.getValue().toFixed(2)}`,
    sortingFn: 'alphanumeric',
  }),
  columnHelper.accessor('quantity', {
    header: 'Quantity',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('date', {
    header: 'Date',
    cell: info => info.getValue(),
  }),
];

export const Route = createFileRoute('/dashboard/reports')({
  // Add a search parameter validator to ensure type safety for URL filters
  validateSearch: (search: Record<string, unknown>): ReportsSearch => {
    return {
      category: search.category as string,
      startDate: search.startDate as string,
      endDate: search.endDate as string,
    };
  },
  component: () => {
    const navigate = useNavigate();
    const { category, startDate, endDate } = Route.useSearch(); // Get search params from Router

    // TanStack Query to fetch data based on search params
    const { data: reports, isLoading, error } = useQuery<SalesReport[]>({
      queryKey: ['dashboard', 'salesReports', { category, startDate, endDate }], // Filters are part of the query key!
      queryFn: () => fetchSalesReports({ category, startDate, endDate }),
    });

    const [sorting, setSorting] = useState([]);

    const table = useReactTable({
      data: reports || [],
      columns,
      state: {
        sorting,
      },
      onSortingChange: setSorting,
      getCoreRowModel: getCoreRowModel(),
      getSortedRowModel: getSortedRowModel(),
    });

    // Virtualization setup
    const tableContainerRef = React.useRef<HTMLDivElement>(null);
    const { rows } = table.getRowModel();

    const rowVirtualizer = useVirtualizer({
      count: rows.length,
      getScrollElement: () => tableContainerRef.current,
      estimateSize: () => 44, // Estimated row height in pixels
      overscan: 10, // Render 10 rows above and below visible area
    });

    const virtualRows = rowVirtualizer.getVirtualItems();

    // TanStack Form for filters
    const form = useForm({
      defaultValues: {
        category: category || 'All',
        startDate: startDate || '',
        endDate: endDate || '',
      },
      onSubmit: async ({ value }) => {
        // Update URL search parameters on form submission
        navigate({
          search: {
            category: value.category === 'All' ? undefined : value.category,
            startDate: value.startDate || undefined,
            endDate: value.endDate || undefined,
          },
        });
      },
    });

    if (isLoading) return <p>Loading sales reports...</p>;
    if (error) return <p>Error loading reports: {error.message}</p>;

    return (
      <div>
        <h3>Sales Reports</h3>

        {/* Filter Form */}
        <form
          onSubmit={e => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
          }}
          className="mb-4 p-4 border rounded-lg bg-gray-50 grid grid-cols-1 md:grid-cols-3 gap-4"
        >
          <form.Field name="category">
            {(field) => (
              <div>
                <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Category</label>
                <select
                  id={field.name}
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={e => field.handleChange(e.target.value)}
                  className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
                >
                  <option value="All">All Categories</option>
                  <option value="Electronics">Electronics</option>
                  <option value="Clothing">Clothing</option>
                  <option value="Books">Books</option>
                  <option value="Home Goods">Home Goods</option>
                </select>
              </div>
            )}
          </form.Field>

          <form.Field name="startDate">
            {(field) => (
              <div>
                <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Start Date</label>
                <input
                  id={field.name}
                  name={field.name}
                  type="date"
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={e => field.handleChange(e.target.value)}
                  className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
                />
              </div>
            )}
          </form.Field>

          <form.Field name="endDate">
            {(field) => (
              <div>
                <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">End Date</label>
                <input
                  id={field.name}
                  name={field.name}
                  type="date"
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={e => field.handleChange(e.target.value)}
                  className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
                />
              </div>
            )}
          </form.Field>

          <div className="md:col-span-3 flex justify-end">
            <button
              type="submit"
              className="mt-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
              Apply Filters
            </button>
          </div>
        </form>

        {/* Virtualized Table */}
        <div ref={tableContainerRef} className="h-[500px] overflow-auto border rounded-lg"> {/* Fixed height for scrollable area */}
          <table className="min-w-full divide-y divide-gray-200">
            <thead>
              {table.getHeaderGroups().map(headerGroup => (
                <tr key={headerGroup.id}>
                  {headerGroup.headers.map(header => (
                    <th
                      key={header.id}
                      className="sticky top-0 bg-gray-100 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer z-10"
                      onClick={header.column.getToggleSortingHandler()}
                    >
                      {flexRender(header.column.columnDef.header, header.getContext())}
                      {{
                        asc: ' ๐Ÿ”ผ',
                        desc: ' ๐Ÿ”ฝ',
                      }[header.column.getIsSorted() as string] ?? null}
                    </th>
                  ))}
                </tr>
              ))}
            </thead>
            <tbody
              className="bg-white divide-y divide-gray-200 relative"
              style={{
                height: `${rowVirtualizer.getTotalSize()}px`, // Use virtualizer's total size for height
              }}
            >
              {virtualRows.map(virtualRow => {
                const row = rows[virtualRow.index];
                return (
                  <tr
                    key={row.id}
                    data-index={virtualRow.index} // Helps virtualizer track rows
                    ref={(node) => rowVirtualizer.measureElement(node)} // Measure row height
                    style={{
                      position: 'absolute',
                      transform: `translateY(${virtualRow.start}px)`, // Position row
                      width: '100%',
                    }}
                  >
                    {row.getVisibleCells().map(cell => (
                      <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                        {flexRender(cell.column.columnDef.cell, cell.getContext())}
                      </td>
                    ))}
                  </tr>
                );
              })}
            </tbody>
          </table>
          {!reports?.length && !isLoading && <p className="p-4 text-center text-gray-500">No reports found for the selected filters.</p>}
        </div>
      </div>
    );
  },
});

Key Updates and Explanations:

  • ReportsSearch Type and validateSearch:
    • type ReportsSearch: Defines the expected structure of our URL search parameters, ensuring type safety when accessing them.
    • validateSearch: This TanStack Router option ensures that any incoming URL search parameters conform to our ReportsSearch type. It’s a powerful way to keep your URL state predictable and type-safe.
  • Route.useSearch(): This hook retrieves the current search parameters from the URL, automatically parsed and type-checked by validateSearch.
  • queryKey with Filters: The queryKey for useQuery now includes the category, startDate, and endDate from the URL. This is crucial! Whenever these values change (because the user applies new filters), TanStack Query sees a new queryKey, automatically invalidates the old data, and refetches the reports from our fetchSalesReports function with the updated filters. This is the magic of server-state management.
  • fetchSalesReports Accepts Filters: Our mock API function now takes a filters object and filters the allData array accordingly, simulating a backend filtering operation.
  • TanStack Form Integration (useForm, form.Field):
    • useForm: Initializes our form with defaultValues pulled from the URL’s search parameters, ensuring the form reflects the current URL state on load.
    • form.Field: Renders individual form fields (category select, startDate and endDate inputs). It provides field state (field.state.value) and handlers (field.handleBlur, field.handleChange) for controlled inputs.
    • onSubmit: When the form is submitted, we use navigate from TanStack Router to update the URL’s search parameters. Notice how we set undefined for empty or “All” values; this keeps the URL clean by omitting parameters when they’re not actively set.
  • TanStack Virtual Integration (useVirtualizer):
    • tableContainerRef: A ref attached to the div that will serve as our scrollable container for the virtualized table.
    • useVirtualizer: The core hook for virtualization.
      • count: rows.length: The total number of items to virtualize (our table rows).
      • getScrollElement: () => tableContainerRef.current: Tells the virtualizer which element is responsible for scrolling.
      • estimateSize: () => 44: An estimated height for each row. This is important for initial rendering; the virtualizer will refine this as it measures actual rows.
      • overscan: 10: Renders 10 extra rows above and below the visible viewport to prevent flickering during fast scrolling.
    • Virtualized tbody Rendering:
      • The tbody gets a fixed height based on rowVirtualizer.getTotalSize(), creating a scrollable area that accurately represents the total height of all rows, even if they’re not rendered.
      • We map virtualRows (which are just metadata about which rows should be visible) instead of table.getRowModel().rows.
      • Each virtualized row is absolutely positioned using transform: translateY(${virtualRow.start}px) to place it correctly within the scrollable tbody.
      • ref={(node) => rowVirtualizer.measureElement(node)}: This is vital! It tells the virtualizer to measure the actual height of each rendered row, making the virtualization more accurate over time.

Now, when you navigate to /dashboard/reports, you’ll see a table with 1000 rows (mocked). Try typing into the filter fields and clicking “Apply Filters.” Observe how:

  1. The URL search parameters update.
  2. TanStack Query automatically refetches data.
  3. The table updates with the filtered results.
  4. Scrolling is smooth, thanks to virtualization!

Mini-Challenge: Add Column Filtering

Our current filtering applies broadly to the entire dataset before display. Let’s add a more granular feature: column-specific filtering directly within the table headers for the productName column.

Challenge: Modify the src/routes/dashboard.reports.tsx file to include an input field within the productName column header. This input should allow users to type and filter rows based on the product name.

Hint:

  • You’ll need to use TanStack Table’s getFilteredRowModel plugin.
  • Introduce a globalFilter state (or a column-specific filter state) and pass it to useReactTable.
  • Add an <input> element inside the productName column’s header definition, managing its value and onChange to update the filter state.
  • Remember to update the table.setGlobalFilter or column.setFilterValue based on the input.

What to observe/learn:

  • How to integrate custom UI elements (like input fields) directly into TanStack Table headers.
  • The difference between global filters and column-specific filters in TanStack Table.
  • How updating filter state triggers table re-rendering.

Common Pitfalls & Troubleshooting

  1. Stale Query Data / No Refetch on Filter Change:

    • Pitfall: You change filters, but the data in your table doesn’t update, or it seems stale.
    • Troubleshooting: Double-check your queryKey. If the filter parameters (e.g., category, startDate) are not included in the queryKey array, TanStack Query won’t know that the data needs to be refetched when those parameters change. The queryKey must accurately reflect all dependencies of your queryFn.
    • Example: queryKey: ['dashboard', 'salesReports', { category, startDate, endDate }] is correct. queryKey: ['dashboard', 'salesReports'] would be wrong if category changes should trigger a refetch.
  2. Performance Issues with Large Tables (No Virtualization):

    • Pitfall: Your browser becomes sluggish or crashes when rendering hundreds or thousands of rows in a table.
    • Troubleshooting: This is precisely why TanStack Virtual exists! Ensure you’ve correctly implemented useVirtualizer with getScrollElement, estimateSize, and measureElement on your rows. A common mistake is forgetting to set a fixed height (h-[...]) and overflow-auto on your table’s container div, as the virtualizer needs a scrollable element to detect visibility.
  3. Router Search Params Not Updating Query:

    • Pitfall: You change the URL search parameters, but your useQuery hook doesn’t seem to react.
    • Troubleshooting: Verify that your useQuery’s queryKey includes the search parameters from Route.useSearch(). Also, ensure that validateSearch is correctly parsing the incoming URL parameters into the expected types. If validateSearch returns undefined or an unexpected value for a parameter, Query won’t see a meaningful change in the queryKey.
  4. TanStack Form Initial Values Not Synchronizing with URL:

    • Pitfall: On page load, your form inputs don’t reflect the values present in the URL’s search parameters.
    • Troubleshooting: Make sure useForm’s defaultValues are correctly initialized using the values from Route.useSearch(). For example: defaultValues: { category: category || 'All', ... }. This ensures the form’s initial state matches the URL.

Summary

Congratulations! You’ve successfully built a sophisticated data dashboard, demonstrating your ability to integrate multiple powerful TanStack libraries into a cohesive, performant application.

Here are the key takeaways from this chapter:

  • Integrated Architecture: You learned how to design an application where TanStack Query manages server state, TanStack Router handles navigation and URL-driven state, TanStack Form controls client-side input, and TanStack Table (with Virtual) efficiently displays large datasets.
  • Server State vs. Client State: You reinforced the understanding of when to use Query for remote data and when to use Form/Store for local UI state.
  • Reactive Data Flow: You implemented a robust data flow where user interactions (via Form) update the URL (via Router), which in turn triggers data refetches (via Query), and finally updates the displayed data (in Table).
  • Performance Optimization: You applied virtualization with TanStack Virtual to handle large tables, ensuring a smooth user experience even with thousands of rows.
  • Debugging with Devtools: You integrated both TanStack Router Devtools and React Query Devtools, which are indispensable for understanding and debugging the state of your application.

This project is a powerful testament to the flexibility and efficiency of the TanStack ecosystem. You’re now equipped to build complex, data-driven web applications with confidence and best practices.

What’s Next? In the final chapter, we’ll discuss deploying TanStack applications, advanced error handling strategies, comprehensive testing approaches, and further architectural considerations for scaling your TanStack projects.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.