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:
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.
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 installInstall 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 debuggingreact-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
Producttype to ensure type safety. productsarray acts as our in-memory database.delayfunction simulates network latency, making the app feel more realistic.productApicontains 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.
src/main.tsx- Setup Providers: Modifysrc/main.tsxto wrap ourAppcomponent withQueryClientProviderandRouterProvider.// 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, andReactQueryDevtoolsfrom@tanstack/react-query. - We import
RouterProviderandcreateRouterfrom@tanstack/react-router. routeTree.gen.tsis a file that TanStack Router will generate for us (we’ll define routes next).- The
routeris created, and we passqueryClientinto its context. This is a common pattern to makequeryClientavailable in route loaders. - The
declare moduleblock is crucial for TanStack Router’s type safety, ensuring our router setup is correctly inferred throughout the application.
- We import
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/routesdirectory. Inside it, create__root.tsx,index.tsx,products.tsx,products.$productId.edit.tsx, andproducts.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:
createRootRouteWithContextallows us to inject context (likequeryClient) into our routes.Outletis where child routes will render their content.Linkis used for navigation, similar toNavLinkin other routers.TanStackRouterDevtoolsis 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 callsproductApi.fetchProducts()and then caches the result. This prevents unnecessary fetches.ProductsComponentwill eventually render the list.Outletis important here becauseproducts.createandproducts.$productId.editare 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, weinvalidateQueriesfor['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:
$productIdin the filename indicates a dynamic route segment.- The
loaderhere fetches a single product usingproductApi.fetchProductById. Route.useParams()allows us to easily access URL parameters.onSuccessinvalidates both the specific product query (['product', productId]) and the general list query (['products']) to ensure everything is up-to-date.
Generate Route Tree: After creating these route files, run the TanStack Router CLI command to generate
src/routeTree.gen.ts:npx @tanstack/router-cli generateYou might need to add
"type": "module"to yourpackage.jsonif you encounter issues, or configuretsconfig.jsonfor 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
zodfor robust schema validation:npm install zod. useForm: Initializes the form withdefaultValuesand anonSubmithandler.form.Field: This component is used for each input. It providesfield.state.value,field.handleBlur,field.handleChange, andfield.state.meta.errorsfor 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.jsand import it inindex.cssfor 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.columnsarray: Defines each column, including how to access data (accessor), header content, and cell rendering.- Actions Column: We add a
displaycolumn for actions (Edit and Delete buttons).- The
Editbutton usesLinkto navigate to the/products/$productId/editroute, passing theproductIdinparams. - The
Deletebutton usesuseMutationto callproductApi.deleteProduct. On success, itinvalidateQueriesfor['products']to automatically refresh the list.
- The
useReactTable: The core hook from TanStack Table, which takes our data and column definitions.table.getHeaderGroups().map(...)andtable.getRowModel().rows.map(...): These are used to render the table structure based on thetableinstance.flexRender: A helper to render header and cell content, allowing for flexible components.- Loading and error states are handled based on the query state.
Step 6: Basic Styling (Optional but Recommended)
For the provided code to look decent, you’d typically include Tailwind CSS. Here’s a quick setup if you haven’t already:
- Install Tailwind CSS:
npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p - Configure
tailwind.config.js:// tailwind.config.js /** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], } - 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:
- You’ll need a state variable (e.g.,
searchTerm) to store the input value. - Add an
<input type="text" />element to capture the search term. - Use
table.setGlobalFilter(searchTerm)to apply the filter. You might also need to addgetFilteredRowModel: getFilteredRowModel()touseReactTableoptions. - 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
getFilteredRowModelfor enabling filtering.
Common Pitfalls & Troubleshooting
Stale Data After Mutations:
- Pitfall: You perform a
createProductordeleteProductoperation, but the product list displayed in the table doesn’t update immediately. - Troubleshooting: This almost always means you forgot to
invalidateQueriesafter a successful mutation. Ensure youronSuccesscallbacks foruseMutationcorrectly callqueryClient.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.
- Pitfall: You perform a
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 eachform.Fieldhas itsvalidatorsprop correctly configured, especially if using a schema like Zod. - Render errors: Make sure you are rendering
field.state.meta.errorsfor each field. disabledstate: Verify your submit button’sdisabledprop is correctly bound to!form.state.isValid.onSubmithandler: Ensure youronSubmithandler foruseFormis correctly defined and thatform.handleSubmit()is called on form submission.
- Check
Router Loaders Not Fetching Data:
- Pitfall: A page that relies on
Route.useLoaderData()showsundefinedor an empty state, even though the backend API call should return data. - Troubleshooting:
queryKeymismatch: Double-check that thequeryKeyin yourloader(e.g.,['products']) exactly matches thequeryKeyyou expect to be populated.queryFnerrors: Ensure yourqueryFn(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.ensureQueryDatavsfetchQuery: While both fetch data,ensureQueryDatais generally preferred in loaders as it leverages the cache more effectively. If you’re usingfetchQuery, ensure you’re handling loading/error states in your component.queryClientin context: VerifyqueryClientis passed correctly tocreateRouterand accessed viacontext.queryClientin the loader.
- Pitfall: A page that relies on
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
RouterProviderandQueryClientProviderinmain.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.ensureQueryDatato pre-fetch data for pages.
- Setting up
- TanStack Query for Data Management:
- Using
useQueryimplicitly via route loaders forReadoperations (fetching products). - Employing
useMutationforCreate,Update, andDeleteoperations. - Crucially, using
queryClient.invalidateQueriesafter mutations to keep the UI’s server state fresh.
- Using
- TanStack Form for User Input:
- Creating a reusable
ProductFormcomponent. - Utilizing
useFormandform.Fieldfor managing form state, input binding, and submission. - Integrating
zodfor robust, schema-based client-side validation.
- Creating a reusable
- TanStack Table for Data Display:
- Defining columns with
createColumnHelper. - Rendering the table structure using
useReactTable,getHeaderGroups, andgetRowModel. - Adding action buttons (Edit, Delete) directly within table cells, demonstrating how to trigger mutations from the table.
- Defining columns with
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
- TanStack Query Official Docs (v5)
- TanStack Router Official Docs (v1)
- TanStack Table Official Docs (v8)
- TanStack Form Official Docs (v0)
- Zod Documentation
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.