Introduction

Welcome to Chapter 14! So far, we’ve explored the individual superpowers of various TanStack libraries: managing server state with Query, handling complex routing with Router, building robust forms with Form, and creating flexible tables with Table. Now, it’s time to bring all these pieces together and build a real-world application!

In this chapter, we’ll embark on a practical project: creating a complete CRUD (Create, Read, Update, Delete) application. This is a fundamental type of application that you’ll encounter in almost any web development scenario. By building this project, you’ll gain hands-on experience integrating TanStack Query for data fetching and mutations, TanStack Router for navigation, TanStack Form for user input, and TanStack Table for displaying data. It’s an exciting opportunity to solidify your understanding and see how these libraries truly shine when used in concert.

Before we dive in, ensure you’re comfortable with the core concepts of each library we’ve covered in previous chapters. We’ll be moving at a steady pace, focusing on the integration aspects rather than re-explaining every fundamental detail. Let’s get building!

Core Concepts: The CRUD Blueprint with TanStack

A CRUD application, at its heart, performs four basic operations on data:

  • Create: Adding new data.
  • Read: Displaying existing data.
  • Update: Modifying existing data.
  • Delete: Removing data.

Let’s map how the TanStack ecosystem elegantly handles each of these:

Read: Effortless Data Display with TanStack Query & Table

For fetching and displaying data, TanStack Query is our go-to. It handles the heavy lifting of caching, revalidation, and background updates, ensuring our UI always shows fresh data without us manually managing loading states or error handling. TanStack Table then takes this fetched data and provides a powerful, headless way to render it, giving us full control over the UI while handling sorting, filtering, and pagination logic.

Create & Update: Forms That Just Work with TanStack Form & Query

When it comes to creating new records or updating existing ones, user input is crucial. TanStack Form provides a robust, type-safe, and framework-agnostic solution for managing form state, validation, and submission. Once a form is submitted, we’ll use TanStack Query’s useMutation hook to send the data to our (simulated) backend. Critically, after a successful mutation (create or update), we’ll use Query’s invalidation mechanism to tell our application that the list of items might have changed, prompting a refetch to keep our UI in sync.

Delete: Streamlined Removal with TanStack Query

Deleting records is another mutation operation. Similar to Create and Update, we’ll leverage TanStack Query’s useMutation to send a delete request to the server. Upon successful deletion, we’ll invalidate the relevant queries to ensure the updated list (without the deleted item) is displayed.

Routing: Seamless Navigation with TanStack Router

A CRUD application typically has different views: a list view, a “create new” view, and an “edit existing” view. TanStack Router provides the perfect solution for managing these distinct routes. We’ll define routes that map to our different components, allowing for smooth navigation and enabling us to pass parameters (like an item ID for editing) directly through the URL, which TanStack Router makes type-safe and easy to access.

The Interplay: A Visual Guide

Imagine a user flow for editing an item:

graph TD A[User Clicks 'Edit'] --> B{TanStack Router Navigates to /items/:id/edit}; B --> C[Router Loader Fetches Item Data TanStack Query]; C --> D[TanStack Form Renders Pre-filled Data]; D --> E[User Modifies Form Fields]; E --> F[User Clicks 'Save']; F --> G{TanStack Form Submits Data}; G --> H[TanStack Query's useMutation Sends Update Request]; H --> I[On Success: Invalidate 'items' Query]; I --> J[TanStack Router Navigates Back to /items List]; J --> K[TanStack Query Refetches 'items' List]; K --> L[TanStack Table Displays Updated List];

This diagram illustrates how these libraries work together, each handling its domain while seamlessly integrating with others to create a cohesive user experience.

Step-by-Step Implementation

Let’s start building our CRUD application. We’ll create a simple “Product Management” application.

Step 1: Project Setup

First, let’s create a new React project using Vite and install our necessary TanStack libraries.

  1. Create a new Vite React project: Open your terminal and run:

    npm create vite@latest my-tanstack-crud -- --template react-ts
    cd my-tanstack-crud
    npm install
    
  2. Install TanStack Libraries: Now, let’s add the core TanStack packages we’ll be using. As of 2026-01-07, these are the latest stable versions.

    npm install @tanstack/react-query@5 @tanstack/react-query-devtools@5 @tanstack/react-router@1 @tanstack/react-table@8 @tanstack/react-form@0
    
    • @tanstack/react-query@5: For server state management.
    • @tanstack/react-query-devtools@5: For debugging react-query.
    • @tanstack/react-router@1: For routing.
    • @tanstack/react-table@8: For displaying data in a table.
    • @tanstack/react-form@0: For building forms.

Step 2: Simulate a Backend API

For simplicity and to focus on the frontend, we’ll create a small in-memory API. This will simulate a server that stores and manipulates our product data.

Create a new file src/api.ts:

// src/api.ts

export type Product = {
  id: string;
  name: string;
  description: string;
  price: number;
  stock: number;
};

// Simulate a database with some initial products
let products: Product[] = [
  { id: 'p001', name: 'Laptop Pro', description: 'Powerful laptop for professionals', price: 1500, stock: 10 },
  { id: 'p002', name: 'Mechanical Keyboard', description: 'RGB backlit keyboard', price: 120, stock: 50 },
  { id: 'p003', name: 'Wireless Mouse', description: 'Ergonomic design, long battery life', price: 50, stock: 100 },
  { id: 'p004', name: 'Monitor 4K', description: '27-inch 4K UHD display', price: 400, stock: 15 },
];

let nextId = products.length + 1; // Simple ID generation

// Helper to simulate network delay
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

export const productApi = {
  // Read all products
  async fetchProducts(): Promise<Product[]> {
    await delay(500); // Simulate network latency
    console.log('API: Fetched products', products);
    return [...products]; // Return a copy to prevent external modification
  },

  // Read a single product by ID
  async fetchProductById(id: string): Promise<Product | undefined> {
    await delay(300);
    const product = products.find(p => p.id === id);
    console.log(`API: Fetched product by ID ${id}`, product);
    return product ? { ...product } : undefined;
  },

  // Create a new product
  async createProduct(newProduct: Omit<Product, 'id'>): Promise<Product> {
    await delay(700);
    const productWithId: Product = {
      ...newProduct,
      id: `p${String(nextId++).padStart(3, '0')}`, // Generate unique ID
    };
    products.push(productWithId);
    console.log('API: Created product', productWithId);
    return { ...productWithId };
  },

  // Update an existing product
  async updateProduct(updatedProduct: Product): Promise<Product> {
    await delay(700);
    const index = products.findIndex(p => p.id === updatedProduct.id);
    if (index === -1) {
      throw new Error(`Product with ID ${updatedProduct.id} not found.`);
    }
    products[index] = { ...updatedProduct }; // Replace with updated product
    console.log('API: Updated product', updatedProduct);
    return { ...updatedProduct };
  },

  // Delete a product by ID
  async deleteProduct(id: string): Promise<void> {
    await delay(500);
    const initialLength = products.length;
    products = products.filter(p => p.id !== id);
    if (products.length === initialLength) {
      throw new Error(`Product with ID ${id} not found for deletion.`);
    }
    console.log(`API: Deleted product with ID ${id}`);
  },
};

Explanation:

  • We define a Product type to ensure type safety.
  • products array acts as our in-memory database.
  • delay function simulates network latency, making the app feel more realistic.
  • productApi contains methods for each CRUD operation, returning Promises to mimic async API calls.
  • Notice the console logs – these will help us understand when our “API” is being called.

Step 3: Configure TanStack Query and Router

We need to set up our QueryClient for TanStack Query and define our routes for TanStack Router.

  1. src/main.tsx - Setup Providers: Modify src/main.tsx to wrap our App component with QueryClientProvider and RouterProvider.

    // src/main.tsx
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
    import { RouterProvider, createRouter } from '@tanstack/react-router';
    import { routeTree } from './routeTree.gen'; // This will be generated soon!
    import './index.css'; // Basic styling
    
    // Create a client
    const queryClient = new QueryClient();
    
    // Create a router
    const router = createRouter({ routeTree, context: { queryClient } });
    
    // Register your router for maximum type safety
    declare module '@tanstack/react-router' {
      interface Register {
        router: typeof router;
      }
    }
    
    ReactDOM.createRoot(document.getElementById('root')!).render(
      <React.StrictMode>
        <QueryClientProvider client={queryClient}>
          <RouterProvider router={router} />
          <ReactQueryDevtools initialIsOpen={false} /> {/* Optional: Devtools */}
        </QueryClientProvider>
      </React.StrictMode>,
    );
    

    Explanation:

    • We import QueryClient, QueryClientProvider, and ReactQueryDevtools from @tanstack/react-query.
    • We import RouterProvider and createRouter from @tanstack/react-router.
    • routeTree.gen.ts is a file that TanStack Router will generate for us (we’ll define routes next).
    • The router is created, and we pass queryClient into its context. This is a common pattern to make queryClient available in route loaders.
    • The declare module block is crucial for TanStack Router’s type safety, ensuring our router setup is correctly inferred throughout the application.
  2. Define Routes (src/routes/index.tsx, src/routes/products.tsx, etc.): TanStack Router uses a file-based routing system (or code-based if preferred). We’ll use file-based for this project.

    First, create a src/routes directory. Inside it, create __root.tsx, index.tsx, products.tsx, products.$productId.edit.tsx, and products.create.tsx.

    • src/routes/__root.tsx (Root Layout): This is the base layout for our application.

      // src/routes/__root.tsx
      import { createRootRouteWithContext, Outlet, Link } from '@tanstack/react-router';
      import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
      import { QueryClient } from '@tanstack/react-query';
      import './__root.css'; // Optional: Root specific styling
      
      interface MyRouterContext {
        queryClient: QueryClient;
      }
      
      export const Route = createRootRouteWithContext<MyRouterContext>()({
        component: () => (
          <>
            <div className="p-2 flex gap-2">
              <Link to="/" className="[&.active]:font-bold">
                Home
              </Link>{' '}
              <Link to="/products" className="[&.active]:font-bold">
                Products
              </Link>
            </div>
            <hr />
            <Outlet /> {/* This is where child routes will render */}
            <TanStackRouterDevtools initialIsOpen={false} /> {/* Optional: Router Devtools */}
          </>
        ),
      });
      

      Explanation:

      • createRootRouteWithContext allows us to inject context (like queryClient) into our routes.
      • Outlet is where child routes will render their content.
      • Link is used for navigation, similar to NavLink in other routers.
      • TanStackRouterDevtools is a handy tool for debugging routes.
      • __root.css (create this file if you want, e.g., body { font-family: sans-serif; })
    • src/routes/index.tsx (Home Page):

      // src/routes/index.tsx
      import { createFileRoute } from '@tanstack/react-router';
      
      export const Route = createFileRoute('/')({
        component: () => (
          <div className="p-2">
            <h3>Welcome to the TanStack Product Manager!</h3>
            <p>Navigate to the Products page to manage your inventory.</p>
          </div>
        ),
      });
      
    • src/routes/products.tsx (Products List Page): This will be our main page to display products. We’ll add the table here later.

      // src/routes/products.tsx
      import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
      import { productApi } from '../api';
      
      export const Route = createFileRoute('/products')({
        // Define a loader to pre-fetch products data
        loader: async ({ context: { queryClient } }) => {
          // Check if products are already in cache, otherwise fetch
          return await queryClient.ensureQueryData({
            queryKey: ['products'],
            queryFn: () => productApi.fetchProducts(),
          });
        },
        component: ProductsComponent,
      });
      
      function ProductsComponent() {
        // The data fetched by the loader is available via useLoaderData
        const products = Route.useLoaderData();
      
        return (
          <div className="p-2">
            <div className="flex justify-between items-center mb-4">
              <h3 className="text-xl font-bold">Product List</h3>
              <Link to="/products/create" className="bg-blue-500 text-white px-4 py-2 rounded">
                Add New Product
              </Link>
            </div>
            {/* Products table will go here */}
            <Outlet /> {/* For nested routes like /products/create or /products/:id/edit */}
          </div>
        );
      }
      

      Explanation:

      • loader: This is a powerful feature of TanStack Router. It allows us to fetch data before the component renders, ensuring the page is ready with data.
      • queryClient.ensureQueryData: This function from TanStack Query is perfect for loaders. It will check if ['products'] data is already in the cache; if so, it returns it immediately. Otherwise, it calls productApi.fetchProducts() and then caches the result. This prevents unnecessary fetches.
      • ProductsComponent will eventually render the list. Outlet is important here because products.create and products.$productId.edit are child routes.
    • src/routes/products.create.tsx (Create Product Page):

      // src/routes/products.create.tsx
      import { createFileRoute, useNavigate } from '@tanstack/react-router';
      import { useMutation, useQueryClient } from '@tanstack/react-query';
      import { productApi, Product } from '../api';
      import { ProductForm } from '../components/ProductForm'; // We'll create this component soon
      
      export const Route = createFileRoute('/products/create')({
        component: CreateProductComponent,
      });
      
      function CreateProductComponent() {
        const queryClient = useQueryClient();
        const navigate = useNavigate();
      
        const createProductMutation = useMutation({
          mutationFn: (newProduct: Omit<Product, 'id'>) => productApi.createProduct(newProduct),
          onSuccess: () => {
            // Invalidate the 'products' query to refetch the list
            queryClient.invalidateQueries({ queryKey: ['products'] });
            navigate({ to: '/products' }); // Navigate back to the list
          },
          onError: (error) => {
            console.error('Failed to create product:', error);
            // Handle error display to user
          }
        });
      
        const handleSubmit = (values: Omit<Product, 'id'>) => {
          createProductMutation.mutate(values);
        };
      
        return (
          <div className="p-2">
            <h3 className="text-xl font-bold mb-4">Create New Product</h3>
            <ProductForm onSubmit={handleSubmit} initialValues={{ name: '', description: '', price: 0, stock: 0 }} />
            {createProductMutation.isPending && <p>Creating product...</p>}
            {createProductMutation.isError && <p className="text-red-500">Error: {createProductMutation.error?.message}</p>}
          </div>
        );
      }
      

      Explanation:

      • useMutation: This hook from TanStack Query is for performing side effects (like creating, updating, deleting data).
      • mutationFn: The actual function that performs the API call.
      • onSuccess: A callback that runs after a successful mutation. Here, we invalidateQueries for ['products'] to ensure the product list refreshes, and then navigate back.
      • ProductForm: This will be a reusable form component we create next.
    • src/routes/products.$productId.edit.tsx (Edit Product Page):

      // src/routes/products.$productId.edit.tsx
      import { createFileRoute, useNavigate } from '@tanstack/react-router';
      import { useMutation, useQueryClient } from '@tanstack/react-query';
      import { productApi, Product } from '../api';
      import { ProductForm } from '../components/ProductForm';
      
      // Define a loader to fetch the specific product for editing
      export const Route = createFileRoute('/products/$productId/edit')({
        loader: async ({ params: { productId }, context: { queryClient } }) => {
          return await queryClient.ensureQueryData({
            queryKey: ['product', productId],
            queryFn: () => productApi.fetchProductById(productId),
          });
        },
        component: EditProductComponent,
      });
      
      function EditProductComponent() {
        const { productId } = Route.useParams(); // Get productId from URL params
        const productToEdit = Route.useLoaderData(); // Get pre-fetched product data
        const queryClient = useQueryClient();
        const navigate = useNavigate();
      
        if (!productToEdit) {
          return <div className="p-2 text-red-500">Product not found!</div>;
        }
      
        const updateProductMutation = useMutation({
          mutationFn: (updatedProduct: Product) => productApi.updateProduct(updatedProduct),
          onSuccess: () => {
            // Invalidate both the specific product and the general products list
            queryClient.invalidateQueries({ queryKey: ['product', productId] });
            queryClient.invalidateQueries({ queryKey: ['products'] });
            navigate({ to: '/products' });
          },
          onError: (error) => {
            console.error('Failed to update product:', error);
            // Handle error display
          }
        });
      
        const handleSubmit = (values: Omit<Product, 'id'>) => {
          updateProductMutation.mutate({ ...values, id: productId });
        };
      
        return (
          <div className="p-2">
            <h3 className="text-xl font-bold mb-4">Edit Product: {productToEdit.name}</h3>
            <ProductForm onSubmit={handleSubmit} initialValues={productToEdit} />
            {updateProductMutation.isPending && <p>Updating product...</p>}
            {updateProductMutation.isError && <p className="text-red-500">Error: {updateProductMutation.error?.message}</p>}
          </div>
        );
      }
      

      Explanation:

      • $productId in the filename indicates a dynamic route segment.
      • The loader here fetches a single product using productApi.fetchProductById.
      • Route.useParams() allows us to easily access URL parameters.
      • onSuccess invalidates both the specific product query (['product', productId]) and the general list query (['products']) to ensure everything is up-to-date.
  3. Generate Route Tree: After creating these route files, run the TanStack Router CLI command to generate src/routeTree.gen.ts:

    npx @tanstack/router-cli generate
    

    You might need to add "type": "module" to your package.json if you encounter issues, or configure tsconfig.json for ES modules. The CLI will automatically detect your route files and create a highly optimized, type-safe route tree.

Step 4: Create Reusable ProductForm Component

Let’s build the form component that will be used for both creating and editing products. This is where TanStack Form shines.

Create a new directory src/components and inside it, src/components/ProductForm.tsx:

// src/components/ProductForm.tsx
import React from 'react';
import { useForm } from '@tanstack/react-form';
import { productApi, Product } from '../api'; // Use productApi for validation examples if needed
import { z } from 'zod'; // For schema-based validation

// We'll use Zod for robust schema validation
const productSchema = z.object({
  name: z.string().min(3, 'Name must be at least 3 characters'),
  description: z.string().min(10, 'Description must be at least 10 characters'),
  price: z.number().positive('Price must be positive'),
  stock: z.number().int().min(0, 'Stock cannot be negative'),
});

type ProductFormValues = Omit<Product, 'id'>;

interface ProductFormProps {
  onSubmit: (values: ProductFormValues) => void;
  initialValues: ProductFormValues;
}

export function ProductForm({ onSubmit, initialValues }: ProductFormProps) {
  const form = useForm({
    defaultValues: initialValues,
    onSubmit: async ({ value }) => {
      // Call the passed onSubmit prop
      onSubmit(value);
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        e.stopPropagation();
        form.handleSubmit();
      }}
      className="space-y-4 p-4 border rounded shadow-sm"
    >
      {/* Name Field */}
      <form.Field
        name="name"
        validators={{
          onChange: productSchema.pick({ name: true }), // Validate on change
        }}
        children={(field) => (
          <div>
            <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-500 text-sm">{field.state.meta.errors.join(', ')}</em>
            )}
          </div>
        )}
      />

      {/* Description Field */}
      <form.Field
        name="description"
        validators={{
          onChange: productSchema.pick({ description: true }),
        }}
        children={(field) => (
          <div>
            <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Description:</label>
            <textarea
              id={field.name}
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              rows={3}
              className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
            />
            {field.state.meta.errors && (
              <em className="text-red-500 text-sm">{field.state.meta.errors.join(', ')}</em>
            )}
          </div>
        )}
      />

      {/* Price Field */}
      <form.Field
        name="price"
        validators={{
          onChange: productSchema.pick({ price: true }),
        }}
        children={(field) => (
          <div>
            <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Price:</label>
            <input
              id={field.name}
              name={field.name}
              type="number"
              step="0.01"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(Number(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-500 text-sm">{field.state.meta.errors.join(', ')}</em>
            )}
          </div>
        )}
      />

      {/* Stock Field */}
      <form.Field
        name="stock"
        validators={{
          onChange: productSchema.pick({ stock: true }),
        }}
        children={(field) => (
          <div>
            <label htmlFor={field.name} className="block text-sm font-medium text-gray-700">Stock:</label>
            <input
              id={field.name}
              name={field.name}
              type="number"
              step="1"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(Number(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-500 text-sm">{field.state.meta.errors.join(', ')}</em>
            )}
          </div>
        )}
      />

      <button
        type="submit"
        disabled={!form.state.isValid}
        className="bg-green-500 text-white px-6 py-2 rounded disabled:opacity-50"
      >
        Save Product
      </button>
    </form>
  );
}

Explanation:

  • We install zod for robust schema validation: npm install zod.
  • useForm: Initializes the form with defaultValues and an onSubmit handler.
  • form.Field: This component is used for each input. It provides field.state.value, field.handleBlur, field.handleChange, and field.state.meta.errors for easy integration with input elements and displaying validation messages.
  • validators: We use Zod schemas to define validation rules for each field. TanStack Form integrates seamlessly with Zod.
  • The submit button is disabled if !form.state.isValid, preventing invalid submissions.
  • Basic Tailwind CSS classes are used for styling (you’d need to set up Tailwind in tailwind.config.js and import it in index.css for these to work visually, but the functionality is independent).

Step 5: Implement the Products Table

Now let’s bring our products list to life using TanStack Table.

Modify src/routes/products.tsx to include the table. We’ll also add a delete button here.

// src/routes/products.tsx (Updated)
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
import { productApi, Product } from '../api';
import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query'; // Import useMutation and useQueryClient

// ... (previous loader and route definition)

export const Route = createFileRoute('/products')({
  loader: async ({ context: { queryClient } }) => {
    return await queryClient.ensureQueryData({
      queryKey: ['products'],
      queryFn: () => productApi.fetchProducts(),
    });
  },
  component: ProductsComponent,
});

const columnHelper = createColumnHelper<Product>();

const columns = [
  columnHelper.accessor('name', {
    header: () => 'Name',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('description', {
    header: () => 'Description',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('price', {
    header: () => 'Price',
    cell: info => `$${info.getValue().toFixed(2)}`,
  }),
  columnHelper.accessor('stock', {
    header: () => 'Stock',
    cell: info => info.getValue(),
  }),
  columnHelper.display({
    id: 'actions',
    header: () => 'Actions',
    cell: ({ row }) => {
      const queryClient = useQueryClient();
      const deleteProductMutation = useMutation({
        mutationFn: (id: string) => productApi.deleteProduct(id),
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ['products'] }); // Refetch products after deletion
        },
        onError: (error) => {
          console.error('Failed to delete product:', error);
          alert('Error deleting product. See console for details.');
        }
      });

      const handleDelete = () => {
        if (confirm(`Are you sure you want to delete "${row.original.name}"?`)) {
          deleteProductMutation.mutate(row.original.id);
        }
      };

      return (
        <div className="flex gap-2">
          <Link
            to="/products/$productId/edit"
            params={{ productId: row.original.id }}
            className="text-blue-600 hover:underline"
          >
            Edit
          </Link>
          <button
            onClick={handleDelete}
            disabled={deleteProductMutation.isPending}
            className="text-red-600 hover:underline disabled:opacity-50"
          >
            {deleteProductMutation.isPending ? 'Deleting...' : 'Delete'}
          </button>
        </div>
      );
    },
  }),
];

function ProductsComponent() {
  const products = Route.useLoaderData();
  const queryClient = useQueryClient(); // For potential manual query invalidation if needed
  const { isPending, isError, error } = queryClient.getQueryState(['products']) || {}; // Get query state if needed

  const table = useReactTable({
    data: products,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  if (isPending) return <div className="p-2">Loading products...</div>;
  if (isError) return <div className="p-2 text-red-500">Error loading products: {error?.message}</div>;

  return (
    <div className="p-2">
      <div className="flex justify-between items-center mb-4">
        <h3 className="text-xl font-bold">Product List</h3>
        <Link to="/products/create" className="bg-blue-500 text-white px-4 py-2 rounded">
          Add New Product
        </Link>
      </div>

      <div className="overflow-x-auto">
        <table className="min-w-full bg-white border border-gray-200">
          <thead>
            {table.getHeaderGroups().map(headerGroup => (
              <tr key={headerGroup.id} className="bg-gray-100 border-b">
                {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">
                    {header.isPlaceholder
                      ? null
                      : flexRender(header.column.columnDef.header, header.getContext())}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map(row => (
              <tr key={row.id} className="border-b hover:bg-gray-50">
                {row.getVisibleCells().map(cell => (
                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <Outlet /> {/* For nested routes like /products/create or /products/:id/edit */}
    </div>
  );
}

Explanation:

  • createColumnHelper: A utility to define columns in a type-safe way.
  • columns array: Defines each column, including how to access data (accessor), header content, and cell rendering.
  • Actions Column: We add a display column for actions (Edit and Delete buttons).
    • The Edit button uses Link to navigate to the /products/$productId/edit route, passing the productId in params.
    • The Delete button uses useMutation to call productApi.deleteProduct. On success, it invalidateQueries for ['products'] to automatically refresh the list.
  • useReactTable: The core hook from TanStack Table, which takes our data and column definitions.
  • table.getHeaderGroups().map(...) and table.getRowModel().rows.map(...): These are used to render the table structure based on the table instance.
  • flexRender: A helper to render header and cell content, allowing for flexible components.
  • Loading and error states are handled based on the query state.

For the provided code to look decent, you’d typically include Tailwind CSS. Here’s a quick setup if you haven’t already:

  1. Install Tailwind CSS:
    npm install -D tailwindcss postcss autoprefixer
    npx tailwindcss init -p
    
  2. Configure tailwind.config.js:
    // tailwind.config.js
    /** @type {import('tailwindcss').Config} */
    export default {
      content: [
        "./index.html",
        "./src/**/*.{js,ts,jsx,tsx}",
      ],
      theme: {
        extend: {},
      },
      plugins: [],
    }
    
  3. Add Tailwind directives to src/index.css:
    /* src/index.css */
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
        'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
        sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
    }
    
    code {
      font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
        monospace;
    }
    

Now, when you run npm run dev, your application should have basic styling applied.

Step 7: Run the Application

Now, run your development server:

npm run dev

Open your browser to the address provided (usually http://localhost:5173).

Observe:

  • You should see the “Home” and “Products” links.
  • Navigate to “Products”. You’ll see the list of products fetched from our simulated API.
  • Click “Add New Product” to create a new one. Try submitting with invalid data to see validation messages.
  • Click “Edit” on an existing product. The form should be pre-filled.
  • Click “Delete” to remove a product.

You’ve built a full CRUD application using the TanStack ecosystem!

Mini-Challenge: Add a Search Filter to the Table

Now that you have a working CRUD app, let’s add a common feature: client-side search/filtering for the product list.

Challenge: Modify the ProductsComponent in src/routes/products.tsx to include a search input field. As the user types, the table should dynamically filter the displayed products by name or description.

Hint:

  1. You’ll need a state variable (e.g., searchTerm) to store the input value.
  2. Add an <input type="text" /> element to capture the search term.
  3. Use table.setGlobalFilter(searchTerm) to apply the filter. You might also need to add getFilteredRowModel: getFilteredRowModel() to useReactTable options.
  4. Consider debouncing the input to prevent excessive re-renders on every keystroke. (Optional, but good practice!)

What to observe/learn:

  • How to integrate user input to dynamically update table data.
  • The power of TanStack Table’s built-in filtering capabilities.
  • The importance of getFilteredRowModel for enabling filtering.

Common Pitfalls & Troubleshooting

  1. Stale Data After Mutations:

    • Pitfall: You perform a createProduct or deleteProduct operation, but the product list displayed in the table doesn’t update immediately.
    • Troubleshooting: This almost always means you forgot to invalidateQueries after a successful mutation. Ensure your onSuccess callbacks for useMutation correctly call queryClient.invalidateQueries({ queryKey: ['products'] }) (and specific product queries if applicable, like ['product', productId]). Remember, TanStack Query won’t refetch data unless it knows it’s potentially stale.
  2. Form Validation Not Working/Showing Errors:

    • Pitfall: You submit the form with invalid data, but no error messages appear, or the form submits anyway.
    • Troubleshooting:
      • Check validators: Ensure each form.Field has its validators prop correctly configured, especially if using a schema like Zod.
      • Render errors: Make sure you are rendering field.state.meta.errors for each field.
      • disabled state: Verify your submit button’s disabled prop is correctly bound to !form.state.isValid.
      • onSubmit handler: Ensure your onSubmit handler for useForm is correctly defined and that form.handleSubmit() is called on form submission.
  3. Router Loaders Not Fetching Data:

    • Pitfall: A page that relies on Route.useLoaderData() shows undefined or an empty state, even though the backend API call should return data.
    • Troubleshooting:
      • queryKey mismatch: Double-check that the queryKey in your loader (e.g., ['products']) exactly matches the queryKey you expect to be populated.
      • queryFn errors: Ensure your queryFn (e.g., productApi.fetchProducts()) is correctly implemented and doesn’t throw an unhandled error. Use browser dev tools (Network tab) to see if the API call is even being made and what its response is.
      • ensureQueryData vs fetchQuery: While both fetch data, ensureQueryData is generally preferred in loaders as it leverages the cache more effectively. If you’re using fetchQuery, ensure you’re handling loading/error states in your component.
      • queryClient in context: Verify queryClient is passed correctly to createRouter and accessed via context.queryClient in the loader.

Summary

Phew! You’ve just built a fully functional CRUD application by integrating key TanStack libraries. Here’s what we covered:

  • Project Setup: Initializing a React project with Vite and installing @tanstack/react-query, @tanstack/react-router, @tanstack/react-table, and @tanstack/react-form.
  • Simulated Backend: Creating a simple in-memory API (src/api.ts) to mimic server interactions and provide data.
  • TanStack Router Integration:
    • Setting up RouterProvider and QueryClientProvider in main.tsx.
    • Defining routes using file-based routing (__root.tsx, index.tsx, products.tsx, products.create.tsx, products.$productId.edit.tsx).
    • Leveraging route loaders with queryClient.ensureQueryData to pre-fetch data for pages.
  • TanStack Query for Data Management:
    • Using useQuery implicitly via route loaders for Read operations (fetching products).
    • Employing useMutation for Create, Update, and Delete operations.
    • Crucially, using queryClient.invalidateQueries after mutations to keep the UI’s server state fresh.
  • TanStack Form for User Input:
    • Creating a reusable ProductForm component.
    • Utilizing useForm and form.Field for managing form state, input binding, and submission.
    • Integrating zod for robust, schema-based client-side validation.
  • TanStack Table for Data Display:
    • Defining columns with createColumnHelper.
    • Rendering the table structure using useReactTable, getHeaderGroups, and getRowModel.
    • Adding action buttons (Edit, Delete) directly within table cells, demonstrating how to trigger mutations from the table.

You now have a solid foundation for building complex data-driven applications with the TanStack ecosystem. This project demonstrates the power of these libraries working together harmoniously, providing type safety, excellent developer experience, and robust solutions for common web development challenges.

What’s Next? In the next chapter, we’ll dive deeper into Performance Optimization and Advanced Patterns across the TanStack ecosystem, including virtualization for large datasets, memoization strategies, and more sophisticated caching techniques.

References


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