A Comprehensive Guide to the TanStack Ecosystem


A Comprehensive Guide to the TanStack Ecosystem

Welcome to this comprehensive guide to the TanStack ecosystem! This document is designed for absolute beginners, aiming to provide a clear and step-by-step introduction to the powerful collection of libraries that make up TanStack. By the end of this guide, you will have a solid understanding of what TanStack is, why it’s so popular, and how to start building efficient and scalable web applications using its core components.

1. Introduction to TanStack

What is TanStack?

TanStack is not a single framework but a collection of high-quality, open-source libraries that provide powerful utilities for web development. While often associated with React, many of these libraries are “framework-agnostic,” meaning they can be used with other popular frontend frameworks like Vue, Solid, Svelte, and Angular.

The core philosophy behind TanStack is to solve common, recurring challenges in modern web applications, such as:

  • Asynchronous State Management and Data Fetching: Handling data from APIs, including caching, synchronization, and updates.
  • Routing and Navigation: Managing application routes, URL parameters, and overall navigation flow with type safety.
  • Form Management: Building performant forms with robust validation and submission handling.
  • Table and Data Grid Management: Efficiently displaying and interacting with large datasets in tabular form.
  • List Virtualization: Optimizing the rendering of long lists to maintain smooth performance.

Why Learn TanStack? (Benefits, Use Cases, Industry Relevance)

Learning TanStack offers numerous benefits for developers:

  • Improved Developer Experience (DX): TanStack libraries are designed with developer ergonomics in mind. They reduce boilerplate code, offer intuitive APIs, and provide excellent TypeScript support, leading to fewer runtime errors and better autocompletion.
  • Enhanced Performance: Features like intelligent caching, background refetching, and list virtualization significantly improve application speed and responsiveness, even with large datasets.
  • Scalability: The modular and composable nature of TanStack libraries makes them suitable for projects of all sizes, from small prototypes to large-scale enterprise applications.
  • Framework Agnosticism: While popular in the React ecosystem, many TanStack libraries can be used with other frameworks, making your skills transferable.
  • Problem-Solving Focus: Instead of reinventing the wheel for common tasks like data fetching or table rendering, TanStack provides battle-tested solutions that save development time and effort.
  • Industry Relevance: TanStack libraries, especially TanStack Query and TanStack Table, are widely adopted in the industry by companies building modern web applications.

Common Use Cases:

  • Data-heavy dashboards and admin panels: Efficiently displaying and interacting with large amounts of data.
  • Real-time collaboration tools: Keeping UI in sync with backend changes.
  • Complex forms and workflows: Managing form state, validation, and submission with ease.
  • Single-page applications (SPAs): Providing seamless navigation and optimized data loading.
  • E-commerce platforms: Handling product listings, user data, and order management.
  • AI-powered interfaces: Building performant frontends for applications leveraging AI.

A Brief History

TanStack was created by Tanner Linsley, a prominent figure in the JavaScript open-source community. Many of the libraries were initially released with a “React” prefix (e.g., React Query, React Table) and gained immense popularity within the React ecosystem. Over time, as these libraries evolved to support other frameworks, they were rebranded under the “TanStack” umbrella to reflect their framework-agnostic nature. This unified branding signifies a cohesive ecosystem of high-quality, headless utilities.

Setting up your Development Environment

To follow along with the examples in this document, you’ll need a basic development environment set up for JavaScript/TypeScript and React.

Prerequisites:

  1. Node.js and npm (Node Package Manager): Ensure you have a recent version installed. You can download it from nodejs.org.
    • To check if installed:
      node -v
      npm -v
      
  2. Code Editor: Visual Studio Code (VS Code) is highly recommended due to its excellent JavaScript/TypeScript support and extensive extensions.
  3. Basic understanding of React: This guide assumes you have a fundamental grasp of React concepts like components, props, state, and hooks (useState, useEffect).

Step-by-step instructions to create a new React project with Vite:

Vite is a fast and lightweight build tool that is commonly used with TanStack libraries.

  1. Open your terminal or command prompt.
  2. Create a new React project using Vite:
    npm create vite@latest my-tanstack-app -- --template react-ts
    
    • my-tanstack-app: You can replace this with your desired project name.
    • --template react-ts: Specifies a React project with TypeScript.
  3. Navigate into your new project directory:
    cd my-tanstack-app
    
  4. Install the project dependencies:
    npm install
    
  5. Start the development server:
    npm run dev
    
    Your browser should automatically open to http://localhost:5173 (or a similar port) displaying the default Vite React welcome page. Keep this server running as you make changes.

2. Core Concepts and Fundamentals

The TanStack ecosystem is vast, but some libraries are used more frequently due to the common challenges they address. We’ll focus on the most widely adopted ones: TanStack Query and TanStack Table, and briefly introduce TanStack Router and TanStack Form.

2.1 TanStack Query: Master Your Asynchronous State

What it is: TanStack Query (formerly React Query) is a powerful library for managing server state in your applications. Server state refers to any data that lives on a remote server (like data from an API) and needs to be fetched, cached, synchronized, and updated in your UI. It completely changes how you think about data fetching, moving from manual useEffect and useState spaghetti code to a declarative and automatic approach.

Why it’s essential: Without TanStack Query, managing server data often involves:

  • useState for data, isLoading, error
  • useEffect to trigger fetches
  • Manual caching logic
  • Complex retry and refetching mechanisms
  • Prop drilling to pass data down the component tree

TanStack Query automates all these complexities, making your data fetching logic cleaner, more robust, and more performant.

Core Concepts:

  • QueryClient and QueryClientProvider: The QueryClient is the brain of TanStack Query, managing all your queries, caches, and background updates. The QueryClientProvider makes this client available to all components within its scope. You typically set this up at the root of your application.
  • queryKey: A unique array used to identify and cache your queries. If two queries have the same queryKey, they will share the same data and cache. This is crucial for efficient data management.
  • queryFn: An asynchronous function that performs the actual data fetching (e.g., using fetch or axios). This function must return a Promise that resolves to your data or throws an error.
  • Query States (isLoading, isError, isSuccess, data, error): The useQuery hook provides these properties to declaratively manage the UI based on the query’s current state.
  • Stale-While-Revalidate (SWR): A caching strategy where cached (stale) data is immediately shown while a new network request is made in the background to revalidate it. This provides an instant UI experience while ensuring data freshness.

Code Example: Basic Data Fetching with useQuery

  1. Install TanStack Query:

    npm install @tanstack/react-query @tanstack/react-query-devtools
    

    @tanstack/react-query-devtools provides a useful browser extension for debugging your queries.

  2. Set up QueryClient and QueryClientProvider (in src/main.tsx or src/App.tsx):

    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import App from './App.tsx';
    import './index.css';
    
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
    
    // Create a client
    const queryClient = new QueryClient();
    
    ReactDOM.createRoot(document.getElementById('root')!).render(
      <React.StrictMode>
        <QueryClientProvider client={queryClient}>
          <App />
          <ReactQueryDevtools initialIsOpen={false} /> {/* Optional: Devtools */}
        </QueryClientProvider>
      </React.StrictMode>,
    );
    
  3. Create a component to fetch data (e.g., src/Posts.tsx):

    import React from 'react';
    import { useQuery } from '@tanstack/react-query';
    
    interface Post {
      id: number;
      title: string;
      body: string;
      userId: number;
    }
    
    const fetchPosts = async (): Promise<Post[]> => {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts');
      if (!response.ok) {
        throw new Error('Failed to fetch posts');
      }
      return response.json();
    };
    
    function Posts() {
      const { data, isLoading, isError, error } = useQuery<Post[], Error>({
        queryKey: ['posts'], // Unique key for this query
        queryFn: fetchPosts, // Function to fetch data
      });
    
      if (isLoading) {
        return <div>Loading posts...</div>;
      }
    
      if (isError) {
        return <div>Error: {error.message}</div>;
      }
    
      return (
        <div>
          <h2>Latest Posts</h2>
          <ul>
            {data?.slice(0, 5).map((post) => (
              <li key={post.id}>
                <h3>{post.title}</h3>
                <p>{post.body}</p>
              </li>
            ))}
          </ul>
        </div>
      );
    }
    
    export default Posts;
    
  4. Use the Posts component in src/App.tsx:

    import Posts from './Posts';
    import './App.css';
    
    function App() {
      return (
        <div className="App">
          <h1>My TanStack App</h1>
          <Posts />
        </div>
      );
    }
    
    export default App;
    

Explanation:

  • We defined fetchPosts as our queryFn, which simply fetches data from a public API.
  • useQuery is called with a queryKey (['posts']) and queryFn.
  • It automatically manages isLoading, isError, and data states, simplifying our component logic.
  • Try navigating away from the page and coming back; you’ll notice the data loads instantly because it’s cached! TanStack Query silently revalidates it in the background if it’s “stale.”

Exercise 2.1: Fetching User Data

Modify the Posts component or create a new Users component that fetches data from https://jsonplaceholder.typicode.com/users. Display a list of user names and their email addresses.

Instructions:

  1. Create a new UserList.tsx file.
  2. Define an interface for the user data.
  3. Implement a fetchUsers async function.
  4. Use useQuery with an appropriate queryKey.
  5. Render the loading, error, and data states in your component.
  6. Integrate UserList into your App.tsx.

2.2 TanStack Table: Building Powerful Data Grids

What it is: TanStack Table (formerly React Table) is a “headless” UI library for building powerful and customizable tables and data grids. Being “headless” means it provides all the core logic for table features (sorting, filtering, pagination, grouping, etc.) but gives you 100% control over the rendering and styling. You write the HTML/JSX and CSS yourself, allowing for pixel-perfect designs and seamless integration with any UI library (like Material UI, Tailwind CSS, or your custom components).

Why it’s essential: Building complex tables from scratch with features like sorting, filtering, and pagination is a significant undertaking. TanStack Table abstracts away this complexity, providing robust hooks and utilities, while allowing complete flexibility in how your table looks and feels.

Core Concepts:

  • useReactTable Hook: The primary hook that you pass your data, column definitions, and features (like getCoreRowModel). It returns a table instance that provides all the necessary APIs to render your table.
  • ColumnDef: An array of objects defining your table’s columns. Each ColumnDef specifies how data for a particular column should be accessed (accessorKey) and how its header and cells should be rendered (header, cell).
  • flexRender: A utility function used to render the content of headers and cells based on the table instance. This allows TanStack Table to inject the correct data and context into your rendering functions.
  • Row Model: The processed data rows ready for rendering. getCoreRowModel() is often used to get the basic row model. Additional functions like getSortedRowModel(), getFilteredRowModel(), getPaginationRowModel() are used to apply specific table features.
  • Headless Design: Emphasizes that the library provides logic, not UI. You are responsible for the actual <table>, <thead>, <tbody>, <tr>, <th>, and <td> elements.

Code Example: Basic Table with Sorting

  1. Install TanStack Table:

    npm install @tanstack/react-table
    
  2. Create a DataTable.tsx component:

    import React, { useState } from 'react';
    import {
      ColumnDef,
      flexRender,
      getCoreRowModel,
      useReactTable,
      getSortedRowModel, // Import for sorting
      SortingState, // Import for sorting state
    } from '@tanstack/react-table';
    
    // Define the shape of our data
    type Person = {
      firstName: string;
      lastName: string;
      age: number;
      visits: number;
      status: 'active' | 'inactive';
    };
    
    // Sample data
    const defaultData: Person[] = [
      { firstName: 'John', lastName: 'Doe', age: 30, visits: 100, status: 'active' },
      { firstName: 'Jane', lastName: 'Smith', age: 25, visits: 50, status: 'inactive' },
      { firstName: 'Peter', lastName: 'Jones', age: 35, visits: 200, status: 'active' },
      { firstName: 'Alice', lastName: 'Brown', age: 28, visits: 75, status: 'inactive' },
    ];
    
    // Define columns
    const columns: ColumnDef<Person>[] = [
      {
        accessorKey: 'firstName',
        header: 'First Name',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'lastName',
        header: 'Last Name',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'age',
        header: 'Age',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'visits',
        header: 'Visits',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'status',
        header: 'Status',
        cell: (info) => info.getValue(),
      },
    ];
    
    function DataTable() {
      const [data] = useState(() => [...defaultData]);
      const [sorting, setSorting] = useState<SortingState>([]); // State for sorting
    
      const table = useReactTable({
        data,
        columns,
        getCoreRowModel: getCoreRowModel(),
        onSortingChange: setSorting, // Handle sorting state changes
        getSortedRowModel: getSortedRowModel(), // Apply sorting logic
        state: {
          sorting, // Pass sorting state to the table
        },
      });
    
      return (
        <div className="p-2">
          <table>
            <thead>
              {table.getHeaderGroups().map((headerGroup) => (
                <tr key={headerGroup.id}>
                  {headerGroup.headers.map((header) => (
                    <th key={header.id} colSpan={header.colSpan}>
                      {header.isPlaceholder ? null : (
                        <div
                          {...{
                            className: header.column.getCanSort()
                              ? 'cursor-pointer select-none'
                              : '',
                            onClick: header.column.getToggleSortingHandler(),
                          }}
                        >
                          {flexRender(
                            header.column.columnDef.header,
                            header.getContext(),
                          )}
                          {{
                            asc: ' 🔼',
                            desc: ' 🔽',
                          }[header.column.getIsSorted() as string] ?? null}
                        </div>
                      )}
                    </th>
                  ))}
                </tr>
              ))}
            </thead>
            <tbody>
              {table.getRowModel().rows.map((row) => (
                <tr key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
          <style jsx>{`
            table {
              width: 100%;
              border-collapse: collapse;
            }
            th, td {
              border: 1px solid #ddd;
              padding: 8px;
              text-align: left;
            }
            th {
              background-color: #f2f2f2;
            }
            .cursor-pointer {
              cursor: pointer;
            }
            .select-none {
              user-select: none;
            }
          `}</style>
        </div>
      );
    }
    
    export default DataTable;
    
  3. Use DataTable in your src/App.tsx:

    import DataTable from './DataTable';
    import './App.css';
    
    function App() {
      return (
        <div className="App">
          <h1>My TanStack App</h1>
          <h2>User Data Table</h2>
          <DataTable />
        </div>
      );
    }
    
    export default App;
    

Explanation:

  • We define Person and ColumnDef to type our data and columns.
  • useReactTable takes our data, columns, and row model accessor.
  • We added sorting state, onSortingChange, and getSortedRowModel() to enable and manage sorting.
  • The <th> elements now have onClick handlers to toggle sorting, and visual indicators (🔼/🔽).
  • flexRender is used for both headers and cells to render their content.
  • Basic CSS is included to make the table readable.

Exercise 2.2: Add Pagination to the Table

Modify the DataTable component to include basic pagination.

Instructions:

  1. Import getPaginationRowModel from @tanstack/react-table.
  2. Add a pagination state using useState.
  3. Pass onPaginationChange: setPagination and getPaginationRowModel: getPaginationRowModel() to useReactTable.
  4. Add buttons for “Previous Page” and “Next Page” and display the current page and total pages.
  5. Use table.getCanPreviousPage(), table.getCanNextPage(), table.setPageIndex(), table.getState().pagination.pageIndex, and table.getPageCount() to manage pagination.

2.3 TanStack Router: Type-Safe Client-Side Navigation

What it is: TanStack Router is a powerful, type-safe routing library designed for client-side and full-stack applications. It offers features like nested routing, data loading within routes, and first-class TypeScript support, making navigation predictable and robust.

Why it’s essential: As applications grow, managing routes, especially with dynamic parameters and associated data, can become complex. TanStack Router simplifies this by providing a robust and type-safe API, preventing common routing-related bugs.

Core Concepts:

  • File-Based Routing (with plugin): Automatically generates route definitions from your file structure (e.g., src/routes/about.tsx becomes /about).
  • Code-Based Routing: Manually defining your route tree.
  • Nested Routes: Allows parent-child relationships between routes, enabling shared layouts and hierarchical data loading.
  • Type Safety: Provides end-to-end type safety for path parameters, search parameters, and navigation functions, catching errors at compile time.
  • Route Loaders: Functions that fetch data specifically for a route, ensuring data is available before the component renders, and integrates seamlessly with TanStack Query.

Code Example: Basic Routing

  1. Install TanStack Router:

    npm install @tanstack/react-router
    

    If you plan to use file-based routing with Vite, also install the plugin:

    npm install @tanstack/router-vite-plugin --save-dev
    
  2. Configure Vite (if using file-based routing) - vite.config.ts:

    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
    
    export default defineConfig({
      plugins: [react(), TanStackRouterVite()],
    });
    
  3. Define your route tree (e.g., src/routeTree.ts or src/routeTree.gen.ts if using file-based auto-generation):

    If using file-based routing, this file will be auto-generated. For now, let’s show a manual setup:

    // src/routes/__root.tsx
    import { createRootRoute, Outlet, Link } from '@tanstack/react-router';
    
    export const Route = createRootRoute({
      component: () => (
        <>
          <div className="p-2 flex gap-2">
            <Link to="/" className="[&.active]:font-bold">
              Home
            </Link>{' '}
            <Link to="/about" className="[&.active]:font-bold">
              About
            </Link>
          </div>
          <hr />
          <Outlet />
        </>
      ),
    });
    
    // src/routes/index.tsx
    import { createFileRoute } from '@tanstack/react-router';
    
    export const Route = createFileRoute('/')({
      component: () => <div>Hello from Home!</div>,
    });
    
    // src/routes/about.tsx
    import { createFileRoute } from '@tanstack/react-router';
    
    export const Route = createFileRoute('/about')({
      component: () => <div>Hello from About!</div>,
    });
    
  4. Create your main router instance and render in src/main.tsx:

    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import { RouterProvider, createRouter } from '@tanstack/react-router';
    import { routeTree } from './routeTree.ts'; // or routeTree.gen.ts if auto-generated
    import './index.css';
    
    // Create a router instance
    const router = createRouter({ routeTree });
    
    // Register the router for type safety
    declare module '@tanstack/react-router' {
      interface Register {
        router: typeof router;
      }
    }
    
    ReactDOM.createRoot(document.getElementById('root')!).render(
      <React.StrictMode>
        <RouterProvider router={router} />
      </React.StrictMode>,
    );
    

Explanation:

  • We define a rootRoute which acts as our main layout, including navigation links. The Outlet component renders the content of nested routes.
  • createFileRoute is used for individual pages.
  • RouterProvider makes the router available to the entire application.
  • The Link component is used for navigation, similar to react-router-dom.
  • The declare module block is crucial for TypeScript to understand your route structure and provide type safety.

Exercise 2.3: Dynamic Route Parameter

Add a dynamic route for a user profile page (e.g., /users/:userId).

Instructions:

  1. Create a new file src/routes/users/$userId.tsx (if using file-based) or extend your routeTree with a dynamic segment.
  2. In this new component, use useParams from @tanstack/react-router to access the userId.
  3. Display the userId in the component.
  4. Add a Link to /users/123 on your home page to test it.

2.4 TanStack Form: Efficient Form Management

What it is: TanStack Form is a headless, performant, and type-safe library for managing form state. It aims to simplify the complexities of form handling, validation, and submission, especially for complex forms, while giving you complete control over your UI.

Why it’s essential: Forms are notoriously tricky in web development, often leading to boilerplate, unnecessary re-renders, and complex validation logic. TanStack Form reduces this burden by managing internal form state efficiently and providing a clean API for validation and submission.

Core Concepts:

  • useForm Hook: The main hook to initialize and interact with your form. You pass it default values, an onSubmit function, and optional validation schemas.
  • FieldApi: Provides detailed state and utilities for individual form fields (value, error, touched, etc.).
  • Headless Nature: Similar to TanStack Table, it provides the logic for form state and validation, but you render the actual input elements (e.g., <input>, <textarea>) and connect them to the form state.
  • Integration with Validation Libraries: Designed to work seamlessly with schema validation libraries like Zod (highly recommended for type-safe validation).

Code Example: Simple Form with Validation using Zod

  1. Install TanStack Form and Zod:

    npm install @tanstack/react-form zod
    
  2. Create a ContactForm.tsx component:

    import React from 'react';
    import { useForm } from '@tanstack/react-form';
    import { z } from 'zod'; // For schema validation
    
    // Define a Zod schema for your form
    const formSchema = z.object({
      name: z.string().min(3, 'Name must be at least 3 characters'),
      email: z.string().email('Invalid email address'),
      message: z.string().min(10, 'Message must be at least 10 characters'),
    });
    
    type ContactFormValues = z.infer<typeof formSchema>;
    
    function ContactForm() {
      const form = useForm<ContactFormValues>({
        defaultValues: {
          name: '',
          email: '',
          message: '',
        },
        onSubmit: async ({ value }) => {
          // Do something with form data, e.g., send to API
          console.log('Form submitted:', value);
          alert('Form submitted successfully!');
          // You might reset the form here
          form.reset();
        },
        validatorAdapter: () => ({
          validate: async (value) => {
            try {
              formSchema.parse(value);
              return {}; // No errors
            } catch (err) {
              if (err instanceof z.ZodError) {
                const fieldErrors: Record<string, string> = {};
                err.errors.forEach((error) => {
                  if (error.path.length > 0) {
                    fieldErrors[error.path[0]] = error.message;
                  }
                });
                return fieldErrors;
              }
              return { form: 'An unexpected error occurred.' };
            }
          },
        }),
      });
    
      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
          }}
          className="space-y-4"
        >
          <div>
            <form.Field
              name="name"
              children={(field) => (
                <>
                  <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Name:</label>
                  <input
                    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 border border-gray-300 rounded-md shadow-sm p-2"
                  />
                  {field.state.meta.errors ? (
                    <em className="text-red-600 text-xs">{field.state.meta.errors.join(', ')}</em>
                  ) : null}
                </>
              )}
            />
          </div>
    
          <div>
            <form.Field
              name="email"
              children={(field) => (
                <>
                  <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Email:</label>
                  <input
                    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 border border-gray-300 rounded-md shadow-sm p-2"
                  />
                  {field.state.meta.errors ? (
                    <em className="text-red-600 text-xs">{field.state.meta.errors.join(', ')}</em>
                  ) : null}
                </>
              )}
            />
          </div>
    
          <div>
            <form.Field
              name="message"
              children={(field) => (
                <>
                  <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Message:</label>
                  <textarea
                    id={field.name}
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    rows={4}
                    className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
                  />
                  {field.state.meta.errors ? (
                    <em className="text-red-600 text-xs">{field.state.meta.errors.join(', ')}</em>
                  ) : null}
                </>
              )}
            />
          </div>
    
          <button
            type="submit"
            disabled={form.state.isSubmitting}
            className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50"
          >
            {form.state.isSubmitting ? 'Submitting...' : 'Submit'}
          </button>
        </form>
      );
    }
    
    export default ContactForm;
    
  3. Use ContactForm in your src/App.tsx:

    import ContactForm from './ContactForm';
    import './App.css';
    
    function App() {
      return (
        <div className="App">
          <h1>My TanStack App</h1>
          <h2>Contact Us</h2>
          <ContactForm />
        </div>
      );
    }
    
    export default App;
    

Explanation:

  • We define a Zod schema (formSchema) to specify the validation rules for each field. This provides type inference and runtime validation.
  • The useForm hook initializes our form state.
  • The validatorAdapter integrates Zod with TanStack Form’s validation system.
  • form.Field is a render prop component that connects our HTML input elements to the form state. It provides field.state.value, field.handleChange, field.handleBlur, and field.state.meta.errors for displaying validation messages.
  • The submit button disables itself while form.state.isSubmitting is true.

Exercise 2.4: Add a Password Field with Confirmation

Extend the ContactForm to include a “password” and “confirm password” field.

Instructions:

  1. Update the Zod schema to include password and confirmPassword fields with validation (e.g., minimum length).
  2. Add a refine method to the schema to ensure password and confirmPassword match.
  3. Add the new input fields using form.Field in the component.
  4. Observe how Zod handles the cross-field validation.

3. Intermediate Topics

3.1 TanStack Query: Mutations and Optimistic Updates

Beyond fetching data, applications often need to change data on the server (e.g., creating, updating, or deleting records). TanStack Query provides the useMutation hook for this purpose, offering powerful features like automatic invalidation and optimistic updates.

Mutations (useMutation):

  • mutationFn: An asynchronous function that sends data to the server.
  • onSuccess: A callback function that runs when the mutation successfully completes. This is often used to invalidate relevant queries, forcing them to refetch and update the UI with the latest data from the server.
  • onError: A callback for when the mutation fails.
  • onMutate: A callback that runs before the mutation function is fired. This is where optimistic updates happen.

Optimistic Updates:

Optimistic updates are a technique to make your UI feel incredibly fast and responsive. When a user performs an action that modifies data (like checking a todo item), you immediately update the UI as if the change was successful, before the server actually confirms it. If the server request fails, you revert the UI to its previous state. This provides an instant feedback loop for the user, improving perceived performance.


Code Example: Adding a Todo with Optimistic Update

  1. Add to your src/App.tsx or create a new component file for a Todo list.

    Make sure your main.tsx still has QueryClientProvider and QueryClient.

    // src/TodoList.tsx
    import React, { useState } from 'react';
    import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
    
    interface Todo {
      id: number;
      title: string;
      completed: boolean;
    }
    
    // Function to fetch todos
    const fetchTodos = async (): Promise<Todo[]> => {
      const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
      if (!response.ok) {
        throw new Error('Failed to fetch todos');
      }
      return response.json();
    };
    
    // Function to add a todo
    const addTodoRequest = async (newTodo: { title: string; completed: boolean }): Promise<Todo> => {
      const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(newTodo),
      });
      if (!response.ok) {
        throw new Error('Failed to add todo');
      }
      return response.json();
    };
    
    function TodoList() {
      const queryClient = useQueryClient();
      const [newTodoTitle, setNewTodoTitle] = useState('');
    
      // Query to fetch todos
      const {
        data: todos,
        isLoading,
        isError,
        error: fetchError,
      } = useQuery<Todo[], Error>({
        queryKey: ['todos'],
        queryFn: fetchTodos,
      });
    
      // Mutation to add a todo with optimistic update
      const addTodoMutation = useMutation<Todo, Error, { title: string; completed: boolean }, { previousTodos: Todo[] }>({
        mutationFn: addTodoRequest,
        onMutate: async (newTodo) => {
          // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
          await queryClient.cancelQueries({ queryKey: ['todos'] });
    
          // Snapshot the previous todos array
          const previousTodos = queryClient.getQueryData(['todos']) || [];
    
          // Optimistically update to the new value
          queryClient.setQueryData(['todos'], (old: Todo[] | undefined) => [
            ...(old || []),
            { id: Date.now(), ...newTodo }, // Assign a temporary ID
          ]);
    
          return { previousTodos }; // Return a context object with the snapshot
        },
        onSuccess: () => {
          // Invalidate and refetch after successful mutation
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
        onError: (err, newTodo, context) => {
          console.error("Mutation failed:", err);
          // Revert back to the old data if the mutation fails
          queryClient.setQueryData(['todos'], context?.previousTodos);
        },
        onSettled: () => {
          // Always refetch after error or success to ensure data is in sync
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
    
      const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (newTodoTitle.trim()) {
          addTodoMutation.mutate({ title: newTodoTitle, completed: false });
          setNewTodoTitle('');
        }
      };
    
      if (isLoading) return <div>Loading todos...</div>;
      if (isError) return <div>Error fetching todos: {fetchError?.message}</div>;
    
      return (
        <div>
          <h2>My Todos</h2>
          <form onSubmit={handleSubmit}>
            <input
              type="text"
              value={newTodoTitle}
              onChange={(e) => setNewTodoTitle(e.target.value)}
              placeholder="Add new todo"
              className="border p-2 mr-2"
            />
            <button
              type="submit"
              disabled={addTodoMutation.isPending}
              className="bg-green-500 text-white p-2 rounded disabled:opacity-50"
            >
              {addTodoMutation.isPending ? 'Adding...' : 'Add Todo'}
            </button>
            {addTodoMutation.isError && (
              <p className="text-red-500">Error adding todo!</p>
            )}
          </form>
          <ul className="list-disc pl-5 mt-4">
            {todos?.map((todo) => (
              <li key={todo.id}>{todo.title} {todo.completed ? '(Completed)' : ''}</li>
            ))}
          </ul>
        </div>
      );
    }
    
    export default TodoList;
    

Explanation:

  • We use useQuery to fetch the initial list of todos.
  • useMutation is set up with addTodoRequest as mutationFn.
  • onMutate: Before sending the request, we optimistically update the UI. We first cancel any pending todos queries to prevent race conditions. Then, we get a snapshot of the current todos and update the local cache with the new todo (assigning a temporary id). This previousTodos is returned as context for potential rollback.
  • onSuccess: If the request succeeds, we invalidateQueries for ['todos']. This tells TanStack Query that the data associated with this key might be out of date and should be refetched in the background (after a short delay, or upon window focus).
  • onError: If the request fails, we use the context.previousTodos to revert the UI to the state before the optimistic update.
  • onSettled: This callback runs whether the mutation succeeds or fails, ensuring that the todos query is ultimately refetched to get the definitive state from the server.

Exercise 3.1: Mark Todo as Completed

Add a button next to each todo item to mark it as completed. Implement this with a new useMutation hook and an optimistic update.

Instructions:

  1. Create an updateTodoRequest function that simulates a PATCH request to update a todo’s completed status.
  2. Implement a new useMutation for marking a todo as completed.
  3. In the onMutate for this new mutation, optimistically update the specific todo item’s completed status in the cache.
  4. Handle onSuccess and onError to invalidate or rollback the change.
  5. Add a button to each <li> element in your TodoList to trigger this mutation.

3.2 TanStack Table: Advanced Features (Filtering, Column Visibility)

Building upon basic tables, TanStack Table excels at handling more complex interactions like global filtering and dynamic column visibility.

Global Filtering:

Allows users to type a search term that filters all columns in the table.

Column Visibility:

Enables users to toggle which columns are visible in the table.


Code Example: Table with Global Filter and Column Visibility

  1. Modify your DataTable.tsx to include these features:

    import React, { useState } from 'react';
    import {
      ColumnDef,
      flexRender,
      getCoreRowModel,
      useReactTable,
      getSortedRowModel,
      SortingState,
      getFilteredRowModel, // Import for filtering
      ColumnFiltersState, // Import for column filters state
      VisibilityState, // Import for column visibility state
    } from '@tanstack/react-table';
    
    type Person = {
      firstName: string;
      lastName: string;
      age: number;
      visits: number;
      status: 'active' | 'inactive';
    };
    
    const defaultData: Person[] = [
      { firstName: 'John', lastName: 'Doe', age: 30, visits: 100, status: 'active' },
      { firstName: 'Jane', lastName: 'Smith', age: 25, visits: 50, status: 'inactive' },
      { firstName: 'Peter', lastName: 'Jones', age: 35, visits: 200, status: 'active' },
      { firstName: 'Alice', lastName: 'Brown', age: 28, visits: 75, status: 'inactive' },
      { firstName: 'Bob', lastName: 'Johnson', age: 40, visits: 120, status: 'active' },
    ];
    
    const columns: ColumnDef<Person>[] = [
      {
        accessorKey: 'firstName',
        header: 'First Name',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'lastName',
        header: 'Last Name',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'age',
        header: 'Age',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'visits',
        header: 'Visits',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'status',
        header: 'Status',
        cell: (info) => info.getValue(),
      },
    ];
    
    function DataTable() {
      const [data] = useState(() => [...defaultData]);
      const [sorting, setSorting] = useState<SortingState>([]);
      const [globalFilter, setGlobalFilter] = useState(''); // State for global filter
      const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); // State for column visibility
    
      const table = useReactTable({
        data,
        columns,
        getCoreRowModel: getCoreRowModel(),
        onSortingChange: setSorting,
        getSortedRowModel: getSortedRowModel(),
        onGlobalFilterChange: setGlobalFilter, // Handle global filter state changes
        getFilteredRowModel: getFilteredRowModel(), // Apply filtering logic
        onColumnVisibilityChange: setColumnVisibility, // Handle column visibility state changes
        state: {
          sorting,
          globalFilter,
          columnVisibility,
        },
      });
    
      return (
        <div className="p-2">
          {/* Global Filter Input */}
          <input
            type="text"
            value={globalFilter ?? ''}
            onChange={(e) => setGlobalFilter(e.target.value)}
            placeholder="Search all columns..."
            className="mb-4 p-2 border border-gray-300 rounded-md"
          />
    
          {/* Column Visibility Checkboxes */}
          <div className="mb-4">
            {table.getAllColumns().map(column => {
              return (
                <div key={column.id} className="inline-block mr-4">
                  <label>
                    <input
                      {...{
                        type: 'checkbox',
                        checked: column.getIsVisible(),
                        onChange: column.getToggleVisibilityHandler(),
                      }}
                      className="mr-1"
                    />{' '}
                    {column.id}
                  </label>
                </div>
              );
            })}
          </div>
    
          <table>
            <thead>
              {table.getHeaderGroups().map((headerGroup) => (
                <tr key={headerGroup.id}>
                  {headerGroup.headers.map((header) => (
                    <th key={header.id} colSpan={header.colSpan}>
                      {header.isPlaceholder ? null : (
                        <div
                          {...{
                            className: header.column.getCanSort()
                              ? 'cursor-pointer select-none'
                              : '',
                            onClick: header.column.getToggleSortingHandler(),
                          }}
                        >
                          {flexRender(
                            header.column.columnDef.header,
                            header.getContext(),
                          )}
                          {{
                            asc: ' 🔼',
                            desc: ' 🔽',
                          }[header.column.getIsSorted() as string] ?? null}
                        </div>
                      )}
                    </th>
                  ))}
                </tr>
              ))}
            </thead>
            <tbody>
              {table.getRowModel().rows.map((row) => (
                <tr key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
          <style jsx>{`
            table {
              width: 100%;
              border-collapse: collapse;
            }
            th, td {
              border: 1px solid #ddd;
              padding: 8px;
              text-align: left;
            }
            th {
              background-color: #f2f2f2;
            }
            .cursor-pointer {
              cursor: pointer;
            }
            .select-none {
              user-select: none;
            }
          `}</style>
        </div>
      );
    }
    
    export default DataTable;
    

Explanation:

  • Global Filter:
    • globalFilter state stores the search string.
    • onGlobalFilterChange: setGlobalFilter updates the state when the input changes.
    • getFilteredRowModel: getFilteredRowModel() is added to useReactTable to apply the filtering logic based on globalFilter.
    • The input element serves as the global filter.
  • Column Visibility:
    • columnVisibility state manages which columns are shown/hidden.
    • onColumnVisibilityChange: setColumnVisibility updates the state.
    • A checkbox for each column is rendered. column.getIsVisible() and column.getToggleVisibilityHandler() are used to control their checked state and toggle visibility.

Exercise 3.2: Per-Column Filtering

Add individual filter inputs to the header of each column (or at least for ‘firstName’ and ‘age’).

Instructions:

  1. Add columnFilters state and onColumnFiltersChange to useReactTable.
  2. In each ColumnDef, you can define a filterFn or simply rely on getFilteredRowModel() for basic string matching.
  3. Inside the header rendering function of your ColumnDef, conditionally render an input field if header.column.getCanFilter() is true.
  4. Bind the input’s value to header.column.getFilterValue() and onChange to header.column.setFilterValue(e.target.value).

4. Advanced Topics and Best Practices

4.1 TanStack Query: Query Invalidation Strategies

Query invalidation is a cornerstone of TanStack Query’s power. It allows you to tell the client that certain cached data might be out of date, prompting a refetch from the server. This is essential for keeping your UI in sync with changes made through mutations or other external events.

Common Invalidation Scenarios:

  • After a successful mutation: As seen in the Todo example, invalidating ['todos'] after adding a new todo ensures the list updates.
  • Polling/Real-time updates: Periodically invalidating a query to fetch the latest data.
  • User-triggered refetch: A “Refresh” button that invalidates a query.
  • Focus refetching: By default, TanStack Query refetches stale queries when the window regains focus, ensuring data is reasonably fresh.

Best Practices:

  • Granular Invalidation: Invalidate only the queries that are affected by a change. For instance, if you update a single user, you might invalidate ['users', userId] rather than ['users'] (unless the list needs a complete refresh).
  • Optimistic Updates with Rollback: Combine optimistic updates with invalidation. This provides immediate UI feedback while ensuring eventual consistency with the server.
  • Query Keys as Source of Truth: Your queryKey should uniquely identify your data. If the data depends on parameters (e.g., a userId), include those parameters in the queryKey (e.g., ['users', userId]). This allows TanStack Query to manage different variations of your data in the cache.
  • staleTime and gcTime Configuration:
    • staleTime: How long data is considered “fresh.” During this time, useQuery will return cached data without refetching. After staleTime, data becomes “stale,” and the next time a component queries it, a background refetch will occur. Default is 0.
    • gcTime (Garbage Collection Time): How long inactive/unused queries are kept in the cache before being garbage collected. Default is 5 minutes. Set this higher for data that’s expensive to refetch but infrequently used, or lower to free up memory faster.

Example: Configuring staleTime and gcTime

In your QueryClient setup (src/main.tsx):

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // Data is considered fresh for 5 minutes
      gcTime: 1000 * 60 * 60,   // Unused data is garbage collected after 1 hour
      // Other options:
      // refetchOnWindowFocus: true,
      // retry: 3,
    },
  },
});

4.2 TanStack Table: Virtualization for Large Datasets (TanStack Virtual)

When dealing with tables containing thousands or millions of rows, simply rendering all rows can lead to severe performance issues. Virtualization (or windowing) is the solution: it only renders the rows that are currently visible within the viewport, plus a small buffer, significantly reducing the number of DOM elements.

TanStack Virtual (formerly React Virtual) is a separate library that integrates perfectly with TanStack Table to provide this functionality. It’s also headless, giving you full control over rendering.

How it works:

Instead of rendering all N rows, TanStack Virtual calculates which subset of rows should be visible based on scroll position. It then tells you the start and size (height) for each visible row, and the total size of the scrollable area. You then use CSS transforms to position the visible rows correctly within a container that represents the “virtual” total height.


Simplified Concept (not a full runnable example, but shows the core idea):

// Inside a component rendering a large table with TanStack Table
import { useVirtualizer } from '@tanstack/react-virtual';
// ... other TanStack Table imports

function LargeDataTable({ data, columns }) {
  // ... TanStack Table setup with useReactTable

  const parentRef = React.useRef(null); // Reference to your scrollable parent element

  const rowVirtualizer = useVirtualizer({
    count: table.getRowCount(), // Total number of rows
    getScrollElement: () => parentRef.current, // The element that scrolls
    estimateSize: () => 35, // Estimated height of each row in pixels
    overscan: 5, // Render 5 extra rows above/below visible area for smooth scrolling
  });

  const virtualRows = rowVirtualizer.getVirtualItems();
  const totalSize = rowVirtualizer.getTotalSize();

  return (
    <div
      ref={parentRef}
      style={{
        height: `500px`, // Fixed height for the scrollable area
        overflow: 'auto', // Enable scrolling
      }}
    >
      <div
        style={{
          height: `${totalSize}px`, // Total virtual height
          position: 'relative',
        }}
      >
        <table>
          <thead>{/* ... table headers */}</thead>
          <tbody>
            {virtualRows.map((virtualRow) => {
              const row = table.getRowModel().rows[virtualRow.index];
              return (
                <tr
                  key={row.id}
                  style={{
                    position: 'absolute',
                    top: 0,
                    left: 0,
                    width: '100%',
                    height: `${virtualRow.size}px`,
                    transform: `translateY(${virtualRow.start}px)`, // Position row
                  }}
                >
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Best Practices for Virtualization:

  • Accurate estimateSize: Provide a good estimate for row height. If row heights vary greatly, you might need to dynamically measure them (more complex, refer to TanStack Virtual docs).
  • overscan value: A small overscan (e.g., 2-5 rows) helps prevent blank spaces during fast scrolling.
  • Performance Trade-offs: Virtualization adds some complexity. Only use it when truly necessary for large lists.

5. Guided Projects

These projects will combine concepts from different TanStack libraries to build small, functional applications.

Project 5.1: Simple Blog with Data Fetching and Routing

Objective: Build a simple blog application that fetches posts from an API, displays a list of posts, and allows users to view individual post details via routing.

Concepts Applied:

  • TanStack Query (useQuery) for data fetching.
  • TanStack Router for navigation and dynamic routes.

Steps:

  1. Setup the Project: If you haven’t already, create a new React + TypeScript project with Vite:

    npm create vite@latest tanstack-blog-app -- --template react-ts
    cd tanstack-blog-app
    npm install
    npm install @tanstack/react-query @tanstack/react-router
    

    Then, configure vite.config.ts for TanStack Router’s file-based routing:

    // vite.config.ts
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
    
    export default defineConfig({
      plugins: [react(), TanStackRouterVite()],
    });
    
  2. Configure TanStack Query and Router in src/main.tsx:

    // src/main.tsx
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import './index.css';
    
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    import { RouterProvider, createRouter } from '@tanstack/react-router';
    import { routeTree } from './routeTree.gen'; // This file is auto-generated by the Vite plugin
    
    const queryClient = new QueryClient();
    const router = createRouter({ routeTree });
    
    declare module '@tanstack/react-router' {
      interface Register {
        router: typeof router;
      }
    }
    
    ReactDOM.createRoot(document.getElementById('root')!).render(
      <React.StrictMode>
        <QueryClientProvider client={queryClient}>
          <RouterProvider router={router} />
        </QueryClientProvider>
      </React.StrictMode>,
    );
    
  3. Create Root Layout (src/routes/__root.tsx): This will be your main layout with navigation.

    // src/routes/__root.tsx
    import { createRootRoute, Outlet, Link } from '@tanstack/react-router';
    
    export const Route = createRootRoute({
      component: () => (
        <>
          <div className="p-4 flex gap-4 bg-gray-100 shadow-md">
            <Link to="/" className="text-blue-600 hover:text-blue-800 font-medium [&.active]:underline">
              Home
            </Link>
            <Link to="/posts" className="text-blue-600 hover:text-blue-800 font-medium [&.active]:underline">
              Posts
            </Link>
          </div>
          <hr className="my-4" />
          <div className="p-4">
            <Outlet />
          </div>
        </>
      ),
    });
    

    (Note: Add basic CSS for “p-4”, “gap-4”, “bg-gray-100”, “shadow-md”, “text-blue-600”, “hover:text-blue-800”, “font-medium”, “[&.active]:underline”, “my-4” by either installing Tailwind CSS or adding a style tag as shown in previous examples)

  4. Create Home Page (src/routes/index.tsx):

    // src/routes/index.tsx
    import { createFileRoute } from '@tanstack/react-router';
    
    export const Route = createFileRoute('/')({
      component: () => (
        <div className="text-center p-8">
          <h2 className="text-2xl font-bold mb-4">Welcome to the TanStack Blog!</h2>
          <p className="text-lg">Explore our latest articles.</p>
        </div>
      ),
    });
    
  5. Create Posts List Page (src/routes/posts/index.tsx): This will fetch and display a list of blog posts.

    // src/routes/posts/index.tsx
    import { createFileRoute, Link } from '@tanstack/react-router';
    import { useQuery } from '@tanstack/react-query';
    
    interface Post {
      id: number;
      title: string;
      body: string;
      userId: number;
    }
    
    const fetchPosts = async (): Promise<Post[]> => {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
      if (!response.ok) {
        throw new Error('Failed to fetch posts');
      }
      return response.json();
    };
    
    export const Route = createFileRoute('/posts')({
      component: PostsListPage,
    });
    
    function PostsListPage() {
      const { data: posts, isLoading, isError, error } = useQuery<Post[], Error>({
        queryKey: ['posts'],
        queryFn: fetchPosts,
      });
    
      if (isLoading) {
        return <div className="text-center text-gray-500">Loading posts...</div>;
      }
    
      if (isError) {
        return <div className="text-center text-red-500">Error: {error.message}</div>;
      }
    
      return (
        <div>
          <h2 className="text-xl font-semibold mb-4">All Posts</h2>
          <ul className="space-y-4">
            {posts?.map((post) => (
              <li key={post.id} className="border p-4 rounded-lg shadow-sm">
                <Link to="/posts/$postId" params={{ postId: post.id.toString() }} className="text-lg font-bold text-blue-600 hover:underline">
                  {post.title}
                </Link>
                <p className="text-gray-700 mt-2">{post.body.substring(0, 100)}...</p>
              </li>
            ))}
          </ul>
        </div>
      );
    }
    
  6. Create Individual Post Detail Page (src/routes/posts/$postId.tsx): This will fetch and display a single post.

    // src/routes/posts/$postId.tsx
    import { createFileRoute } from '@tanstack/react-router';
    import { useQuery } from '@tanstack/react-query';
    
    interface Post {
      id: number;
      title: string;
      body: string;
      userId: number;
    }
    
    const fetchPostById = async (postId: string): Promise<Post> => {
      const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
      if (!response.ok) {
        throw new Error(`Failed to fetch post ${postId}`);
      }
      return response.json();
    };
    
    export const Route = createFileRoute('/posts/$postId')({
      loader: async ({ params }) => {
        // Use TanStack Query's prefetchQuery to load data before component renders
        return await queryClient.prefetchQuery({
          queryKey: ['post', params.postId],
          queryFn: () => fetchPostById(params.postId),
        });
      },
      component: PostDetailPage,
    });
    
    // You might need to import queryClient from main.tsx if it's not globally available
    // For simplicity, assuming queryClient is available in scope for loader example.
    // In a real app, you'd pass it or use a context.
    // This is a placeholder for `queryClient` which should be imported/available globally.
    // In a real scenario, you'd typically have a common utility file for `queryClient`.
    import { queryClient } from '../../main'; // Adjust path if necessary
    
    function PostDetailPage() {
      const { postId } = Route.useParams(); // Get the dynamic parameter from the URL
    
      const { data: post, isLoading, isError, error } = useQuery<Post, Error>({
        queryKey: ['post', postId],
        queryFn: () => fetchPostById(postId),
      });
    
      if (isLoading) {
        return <div className="text-center text-gray-500">Loading post...</div>;
      }
    
      if (isError) {
        return <div className="text-center text-red-500">Error: {error.message}</div>;
      }
    
      if (!post) {
        return <div className="text-center text-gray-500">Post not found.</div>;
      }
    
      return (
        <div className="border p-6 rounded-lg shadow-md bg-white">
          <h2 className="text-2xl font-bold mb-4">{post.title}</h2>
          <p className="text-gray-800 leading-relaxed">{post.body}</p>
          <p className="text-sm text-gray-500 mt-4">Author ID: {post.userId}</p>
          <Link to="/posts" className="mt-6 inline-block text-blue-600 hover:underline">&larr; Back to all posts</Link>
        </div>
      );
    }
    

Guided Exploration:

  1. Run npm run dev and navigate through the application.
  2. Observe how TanStack Query manages the data fetching for posts and individual posts.
  3. Notice how the URL changes and how TanStack Router automatically picks up the postId from the URL.
  4. Try clicking on a post, then navigating back to the posts list, then back to the same post. You should see it load instantly from cache!

Project 5.2: Interactive User Dashboard

Objective: Build a dashboard displaying user data in a sortable and filterable table, with the ability to add new users and immediately see updates.

Concepts Applied:

  • TanStack Table for data display, sorting, and filtering.
  • TanStack Query (useQuery, useMutation) for fetching users and adding new ones with optimistic updates.
  • TanStack Form for adding new users with validation.

Steps:

  1. Setup the Project: If starting fresh, repeat the Vite setup from Project 5.1. Install necessary libraries:

    npm install @tanstack/react-table @tanstack/react-query @tanstack/react-form zod
    

    Ensure src/main.tsx is configured with QueryClientProvider and QueryClient.

  2. Create src/components/UserTable.tsx (Table Component): This will be a general-purpose table component that handles user display, sorting, and filtering.

    import React, { useState } from 'react';
    import {
      ColumnDef,
      flexRender,
      getCoreRowModel,
      useReactTable,
      getSortedRowModel,
      SortingState,
      getFilteredRowModel,
      globalFilterFn,
    } from '@tanstack/react-table';
    
    export interface User {
      id: number;
      name: string;
      username: string;
      email: string;
    }
    
    const columns: ColumnDef<User>[] = [
      {
        accessorKey: 'id',
        header: 'ID',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'name',
        header: 'Name',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'username',
        header: 'Username',
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'email',
        header: 'Email',
        cell: (info) => info.getValue(),
      },
    ];
    
    interface UserTableProps {
      data: User[];
    }
    
    function UserTable({ data }: UserTableProps) {
      const [sorting, setSorting] = useState<SortingState>([]);
      const [globalFilter, setGlobalFilter] = useState('');
    
      const table = useReactTable({
        data,
        columns,
        getCoreRowModel: getCoreRowModel(),
        onSortingChange: setSorting,
        getSortedRowModel: getSortedRowModel(),
        onGlobalFilterChange: setGlobalFilter,
        getFilteredRowModel: getFilteredRowModel(),
        state: {
          sorting,
          globalFilter,
        },
      });
    
      return (
        <div className="p-2">
          <input
            type="text"
            value={globalFilter ?? ''}
            onChange={(e) => setGlobalFilter(e.target.value)}
            placeholder="Search users..."
            className="mb-4 p-2 border border-gray-300 rounded-md"
          />
          <table>
            <thead>
              {table.getHeaderGroups().map((headerGroup) => (
                <tr key={headerGroup.id}>
                  {headerGroup.headers.map((header) => (
                    <th key={header.id} colSpan={header.colSpan}>
                      {header.isPlaceholder ? null : (
                        <div
                          {...{
                            className: header.column.getCanSort()
                              ? 'cursor-pointer select-none'
                              : '',
                            onClick: header.column.getToggleSortingHandler(),
                          }}
                        >
                          {flexRender(
                            header.column.columnDef.header,
                            header.getContext(),
                          )}
                          {{
                            asc: ' 🔼',
                            desc: ' 🔽',
                          }[header.column.getIsSorted() as string] ?? null}
                        </div>
                      )}
                    </th>
                  ))}
                </tr>
              ))}
            </thead>
            <tbody>
              {table.getRowModel().rows.map((row) => (
                <tr key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
          <style jsx>{`
            table {
              width: 100%;
              border-collapse: collapse;
            }
            th, td {
              border: 1px solid #ddd;
              padding: 8px;
              text-align: left;
            }
            th {
              background-color: #f2f2f2;
            }
            .cursor-pointer {
              cursor: pointer;
            }
            .select-none {
              user-select: none;
            }
          `}</style>
        </div>
      );
    }
    
    export default UserTable;
    
  3. Create src/components/AddUserForm.tsx (Form Component): This component will handle adding new users with validation and integrate with TanStack Query mutations.

    import React from 'react';
    import { useForm } from '@tanstack/react-form';
    import { z } from 'zod';
    import { useMutation, useQueryClient } from '@tanstack/react-query';
    import { User } from './UserTable'; // Import User interface
    
    const newUserSchema = z.object({
      name: z.string().min(3, 'Name is required and must be at least 3 characters'),
      username: z.string().min(3, 'Username is required and must be at least 3 characters'),
      email: z.string().email('Invalid email address'),
    });
    
    type NewUserValues = z.infer<typeof newUserSchema>;
    
    const createUserRequest = async (newUser: Omit<User, 'id'>): Promise<User> => {
      const response = await fetch('https://jsonplaceholder.typicode.com/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(newUser),
      });
      if (!response.ok) {
        throw new Error('Failed to create user');
      }
      return response.json();
    };
    
    function AddUserForm() {
      const queryClient = useQueryClient();
    
      const addUserMutation = useMutation<User, Error, Omit<User, 'id'>, { previousUsers: User[] }>({
        mutationFn: createUserRequest,
        onMutate: async (newUser) => {
          await queryClient.cancelQueries({ queryKey: ['users'] });
          const previousUsers = queryClient.getQueryData(['users']) || [];
          queryClient.setQueryData(['users'], (old: User[] | undefined) => [
            ...(old || []),
            { id: Date.now(), ...newUser }, // Optimistically add with a temporary ID
          ]);
          return { previousUsers };
        },
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ['users'] });
          alert('User added successfully!');
        },
        onError: (err, newUser, context) => {
          console.error("Mutation failed:", err);
          queryClient.setQueryData(['users'], context?.previousUsers); // Rollback
          alert(`Error adding user: ${err.message}`);
        },
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['users'] });
        },
      });
    
      const form = useForm<NewUserValues>({
        defaultValues: {
          name: '',
          username: '',
          email: '',
        },
        onSubmit: async ({ value }) => {
          addUserMutation.mutate(value);
          form.reset(); // Reset form after submission attempt
        },
        validatorAdapter: () => ({
          validate: async (value) => {
            try {
              newUserSchema.parse(value);
              return {};
            } catch (err) {
              if (err instanceof z.ZodError) {
                const fieldErrors: Record<string, string> = {};
                err.errors.forEach((error) => {
                  if (error.path.length > 0) {
                    fieldErrors[error.path[0]] = error.message;
                  }
                });
                return fieldErrors;
              }
              return { form: 'An unexpected error occurred.' };
            }
          },
        }),
      });
    
      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
          }}
          className="space-y-4 p-4 border rounded-lg shadow-sm bg-white"
        >
          <h3 className="text-lg font-semibold mb-2">Add New User</h3>
          <div>
            <form.Field
              name="name"
              children={(field) => (
                <>
                  <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Name:</label>
                  <input
                    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 border border-gray-300 rounded-md shadow-sm p-2"
                  />
                  {field.state.meta.errors ? (
                    <em className="text-red-600 text-xs">{field.state.meta.errors.join(', ')}</em>
                  ) : null}
                </>
              )}
            />
          </div>
    
          <div>
            <form.Field
              name="username"
              children={(field) => (
                <>
                  <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Username:</label>
                  <input
                    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 border border-gray-300 rounded-md shadow-sm p-2"
                  />
                  {field.state.meta.errors ? (
                    <em className="text-red-600 text-xs">{field.state.meta.errors.join(', ')}</em>
                  ) : null}
                </>
              )}
            />
          </div>
    
          <div>
            <form.Field
              name="email"
              children={(field) => (
                <>
                  <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Email:</label>
                  <input
                    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 border border-gray-300 rounded-md shadow-sm p-2"
                  />
                  {field.state.meta.errors ? (
                    <em className="text-red-600 text-xs">{field.state.meta.errors.join(', ')}</em>
                  ) : null}
                </>
              )}
            />
          </div>
    
          <button
            type="submit"
            disabled={addUserMutation.isPending}
            className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50"
          >
            {addUserMutation.isPending ? 'Adding User...' : 'Add User'}
          </button>
        </form>
      );
    }
    
    export default AddUserForm;
    
  4. Create src/routes/dashboard.tsx (Dashboard Page): This page will fetch user data and combine the UserTable and AddUserForm components.

    import { createFileRoute } from '@tanstack/react-router';
    import { useQuery } from '@tanstack/react-query';
    import UserTable, { User } from '../components/UserTable';
    import AddUserForm from '../components/AddUserForm';
    
    const fetchUsers = async (): Promise<User[]> => {
      const response = await fetch('https://jsonplaceholder.typicode.com/users?_limit=10');
      if (!response.ok) {
        throw new Error('Failed to fetch users');
      }
      return response.json();
    };
    
    export const Route = createFileRoute('/dashboard')({
      component: DashboardPage,
    });
    
    function DashboardPage() {
      const { data: users, isLoading, isError, error } = useQuery<User[], Error>({
        queryKey: ['users'],
        queryFn: fetchUsers,
      });
    
      if (isLoading) {
        return <div className="text-center text-gray-500">Loading dashboard data...</div>;
      }
    
      if (isError) {
        return <div className="text-center text-red-500">Error loading dashboard: {error.message}</div>;
      }
    
      return (
        <div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-8">
          <div>
            <h2 className="text-2xl font-bold mb-4">User List</h2>
            <UserTable data={users || []} />
          </div>
          <div>
            <h2 className="text-2xl font-bold mb-4">Manage Users</h2>
            <AddUserForm />
          </div>
        </div>
      );
    }
    

    (Note: Add Link to /dashboard in your src/routes/__root.tsx for navigation.)

    // src/routes/__root.tsx - Add this Link
    // ...
    <Link to="/dashboard" className="text-blue-600 hover:text-blue-800 font-medium [&.active]:underline">
      Dashboard
    </Link>
    // ...
    

Guided Exploration:

  1. Run npm run dev and navigate to /dashboard.
  2. Observe the user table populated by useQuery.
  3. Try searching and sorting the table to see TanStack Table in action.
  4. Use the “Add New User” form. Fill it out and submit.
  5. Notice how the new user instantly appears in the table (optimistic update), and then how the data eventually syncs with the server (simulated by the invalidateQueries and refetch).
  6. Try submitting an invalid form to see the validation errors.

6. Bonus Section: Further Learning and Resources

The TanStack ecosystem is constantly evolving, and there’s always more to learn. Here are some excellent resources to continue your journey:

Official Documentation:

  • Zero To Mastery - Beginner’s Guide to React Query (Now TanStack Query): A blog post that serves as a good entry point. (Found via web search)
  • Better Stack Community - TanStack for Beginners: Another comprehensive guide for beginners. (Found via web search)
  • YouTube Tutorials: Search for “TanStack Query tutorial 2025,” “TanStack Table tutorial,” etc. Channels like “LearnWebCode” and “TheDevLogger” often have good content. (Found via web search)

Blogs and Articles:

  • TanStack Blog: The official blog often features updates and deeper dives into the libraries. https://tanstack.com/blog
  • Medium.com: Many developers share their experiences and tutorials on Medium. Search for “TanStack Query” or “TanStack Table” articles. (Found via web search)
  • Dev.to: Similar to Medium, a great platform for developer articles. (Found via web search)

Community Forums/Groups:

  • TanStack Discord: Join the official TanStack Discord server for community support, discussions, and direct interaction with maintainers. Link available on the main TanStack website.
  • Stack Overflow: Search for tanstack-query, tanstack-table, tanstack-router tags.
  • GitHub Discussions: Most TanStack libraries have active GitHub repositories with discussion sections.

Next Steps/Advanced Topics:

  • Server-Side Rendering (SSR) and Server Components: Explore how TanStack libraries integrate with full-stack frameworks like TanStack Start, Next.js, or Remix for SSR and server-side logic.
  • Advanced Caching Strategies: Dive deeper into staleTime, gcTime, refetchOnMount, refetchOnWindowFocus, and custom cache invalidation patterns.
  • Infinite Scrolling and Pagination: Master efficient ways to load large datasets incrementally using TanStack Query’s infinite query capabilities.
  • Custom Filtering and Sorting: Implement more complex filtering and sorting logic in TanStack Table.
  • Complex Form Architectures: Learn about form composition, linked fields, and custom input components with TanStack Form.
  • TanStack Store, DB, and other libraries: Explore other specialized libraries within the TanStack ecosystem as your needs grow.

Congratulations on completing this comprehensive guide to the TanStack ecosystem! You now have a strong foundation to build powerful, performant, and maintainable web applications. Keep experimenting, keep learning, and happy coding!