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:
- 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
- To check if installed:
- Code Editor: Visual Studio Code (VS Code) is highly recommended due to its excellent JavaScript/TypeScript support and extensive extensions.
- 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.
- Open your terminal or command prompt.
- Create a new React project using Vite:
npm create vite@latest my-tanstack-app -- --template react-tsmy-tanstack-app: You can replace this with your desired project name.--template react-ts: Specifies a React project with TypeScript.
- Navigate into your new project directory:
cd my-tanstack-app - Install the project dependencies:
npm install - Start the development server:Your browser should automatically open to
npm run devhttp://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:
useStatefordata,isLoading,erroruseEffectto 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:
QueryClientandQueryClientProvider: TheQueryClientis the brain of TanStack Query, managing all your queries, caches, and background updates. TheQueryClientProvidermakes 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 samequeryKey, 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., usingfetchoraxios). This function must return a Promise that resolves to your data or throws an error.- Query States (
isLoading,isError,isSuccess,data,error): TheuseQueryhook 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
Install TanStack Query:
npm install @tanstack/react-query @tanstack/react-query-devtools@tanstack/react-query-devtoolsprovides a useful browser extension for debugging your queries.Set up
QueryClientandQueryClientProvider(insrc/main.tsxorsrc/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>, );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;Use the
Postscomponent insrc/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
fetchPostsas ourqueryFn, which simply fetches data from a public API. useQueryis called with aqueryKey(['posts']) andqueryFn.- It automatically manages
isLoading,isError, anddatastates, 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:
- Create a new
UserList.tsxfile. - Define an interface for the user data.
- Implement a
fetchUsersasync function. - Use
useQuerywith an appropriatequeryKey. - Render the loading, error, and data states in your component.
- Integrate
UserListinto yourApp.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:
useReactTableHook: The primary hook that you pass your data, column definitions, and features (likegetCoreRowModel). It returns atableinstance that provides all the necessary APIs to render your table.ColumnDef: An array of objects defining your table’s columns. EachColumnDefspecifies 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 likegetSortedRowModel(),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
Install TanStack Table:
npm install @tanstack/react-tableCreate a
DataTable.tsxcomponent: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;Use
DataTablein yoursrc/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
PersonandColumnDefto type our data and columns. useReactTabletakes ourdata,columns, and row model accessor.- We added
sortingstate,onSortingChange, andgetSortedRowModel()to enable and manage sorting. - The
<th>elements now haveonClickhandlers to toggle sorting, and visual indicators (🔼/🔽). flexRenderis 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:
- Import
getPaginationRowModelfrom@tanstack/react-table. - Add a
paginationstate usinguseState. - Pass
onPaginationChange: setPaginationandgetPaginationRowModel: getPaginationRowModel()touseReactTable. - Add buttons for “Previous Page” and “Next Page” and display the current page and total pages.
- Use
table.getCanPreviousPage(),table.getCanNextPage(),table.setPageIndex(),table.getState().pagination.pageIndex, andtable.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.tsxbecomes/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
Install TanStack Router:
npm install @tanstack/react-routerIf you plan to use file-based routing with Vite, also install the plugin:
npm install @tanstack/router-vite-plugin --save-devConfigure 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()], });Define your route tree (e.g.,
src/routeTree.tsorsrc/routeTree.gen.tsif 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>, });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
rootRoutewhich acts as our main layout, including navigation links. TheOutletcomponent renders the content of nested routes. createFileRouteis used for individual pages.RouterProvidermakes the router available to the entire application.- The
Linkcomponent is used for navigation, similar toreact-router-dom. - The
declare moduleblock 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:
- Create a new file
src/routes/users/$userId.tsx(if using file-based) or extend yourrouteTreewith a dynamic segment. - In this new component, use
useParamsfrom@tanstack/react-routerto access theuserId. - Display the
userIdin the component. - Add a
Linkto/users/123on 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:
useFormHook: The main hook to initialize and interact with your form. You pass it default values, anonSubmitfunction, 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
Install TanStack Form and Zod:
npm install @tanstack/react-form zodCreate a
ContactForm.tsxcomponent: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;Use
ContactFormin yoursrc/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
Zodschema (formSchema) to specify the validation rules for each field. This provides type inference and runtime validation. - The
useFormhook initializes our form state. - The
validatorAdapterintegrates Zod with TanStack Form’s validation system. form.Fieldis a render prop component that connects our HTML input elements to the form state. It providesfield.state.value,field.handleChange,field.handleBlur, andfield.state.meta.errorsfor displaying validation messages.- The submit button disables itself while
form.state.isSubmittingis true.
Exercise 2.4: Add a Password Field with Confirmation
Extend the ContactForm to include a “password” and “confirm password” field.
Instructions:
- Update the Zod schema to include
passwordandconfirmPasswordfields with validation (e.g., minimum length). - Add a
refinemethod to the schema to ensurepasswordandconfirmPasswordmatch. - Add the new input fields using
form.Fieldin the component. - 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
Add to your
src/App.tsxor create a new component file for a Todo list.Make sure your
main.tsxstill hasQueryClientProviderandQueryClient.// 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
useQueryto fetch the initial list of todos. useMutationis set up withaddTodoRequestasmutationFn.onMutate: Before sending the request, we optimistically update the UI. We first cancel any pendingtodosqueries to prevent race conditions. Then, we get a snapshot of the currenttodosand update the local cache with the new todo (assigning a temporaryid). ThispreviousTodosis returned ascontextfor potential rollback.onSuccess: If the request succeeds, weinvalidateQueriesfor['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 thecontext.previousTodosto revert the UI to the state before the optimistic update.onSettled: This callback runs whether the mutation succeeds or fails, ensuring that thetodosquery 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:
- Create an
updateTodoRequestfunction that simulates a PATCH request to update a todo’scompletedstatus. - Implement a new
useMutationfor marking a todo as completed. - In the
onMutatefor this new mutation, optimistically update the specific todo item’scompletedstatus in the cache. - Handle
onSuccessandonErrorto invalidate or rollback the change. - Add a button to each
<li>element in yourTodoListto 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
Modify your
DataTable.tsxto 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:
globalFilterstate stores the search string.onGlobalFilterChange: setGlobalFilterupdates the state when the input changes.getFilteredRowModel: getFilteredRowModel()is added touseReactTableto apply the filtering logic based onglobalFilter.- The
inputelement serves as the global filter.
- Column Visibility:
columnVisibilitystate manages which columns are shown/hidden.onColumnVisibilityChange: setColumnVisibilityupdates the state.- A checkbox for each column is rendered.
column.getIsVisible()andcolumn.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:
- Add
columnFiltersstate andonColumnFiltersChangetouseReactTable. - In each
ColumnDef, you can define afilterFnor simply rely ongetFilteredRowModel()for basic string matching. - Inside the
headerrendering function of yourColumnDef, conditionally render an input field ifheader.column.getCanFilter()is true. - Bind the input’s value to
header.column.getFilterValue()andonChangetoheader.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
queryKeyshould uniquely identify your data. If the data depends on parameters (e.g., auserId), include those parameters in thequeryKey(e.g.,['users', userId]). This allows TanStack Query to manage different variations of your data in the cache. staleTimeandgcTimeConfiguration:staleTime: How long data is considered “fresh.” During this time,useQuerywill return cached data without refetching. AfterstaleTime, data becomes “stale,” and the next time a component queries it, a background refetch will occur. Default is0.gcTime(Garbage Collection Time): How long inactive/unused queries are kept in the cache before being garbage collected. Default is5 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). overscanvalue: A smalloverscan(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:
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-routerThen, configure
vite.config.tsfor 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()], });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>, );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
styletag as shown in previous examples)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> ), });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> ); }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">← Back to all posts</Link> </div> ); }
Guided Exploration:
- Run
npm run devand navigate through the application. - Observe how TanStack Query manages the data fetching for posts and individual posts.
- Notice how the URL changes and how TanStack Router automatically picks up the
postIdfrom the URL. - 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:
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 zodEnsure
src/main.tsxis configured withQueryClientProviderandQueryClient.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;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;Create
src/routes/dashboard.tsx(Dashboard Page): This page will fetch user data and combine theUserTableandAddUserFormcomponents.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
Linkto/dashboardin yoursrc/routes/__root.tsxfor 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:
- Run
npm run devand navigate to/dashboard. - Observe the user table populated by
useQuery. - Try searching and sorting the table to see TanStack Table in action.
- Use the “Add New User” form. Fill it out and submit.
- 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
invalidateQueriesand refetch). - 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:
- TanStack Query: https://tanstack.com/query/latest/docs/framework/react/overview
- TanStack Table: https://tanstack.com/table/latest/docs/introduction
- TanStack Router: https://tanstack.com/router/latest/docs/framework/react/overview
- TanStack Form: https://tanstack.com/form/latest/docs
- TanStack Virtual: https://tanstack.com/virtual/latest/docs/introduction
- Main TanStack Website: https://tanstack.com/
Recommended Online Courses/Tutorials:
- 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-routertags. - 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!