TanStack Ecosystem Patterns
Purpose
Provide an overview of the TanStack ecosystem and common patterns shared across TanStack libraries, including type-safe design principles, headless UI architecture, adapter patterns, and integration strategies for React, Vue, Solid, and other frameworks.
Ecosystem Overview
The TanStack ecosystem provides headless, type-safe, framework-agnostic libraries for common frontend needs:
- TanStack Query - Async state management and data fetching
- TanStack Router - Type-safe routing with built-in data loading
- TanStack Table - Headless table/grid UI logic
- TanStack Form - Type-safe form state management
- TanStack Virtual - Virtualization for large lists and grids
- TanStack Start - Full-stack framework built on TanStack Router
Core Design Principles
Headless Architecture
All TanStack libraries provide logic without UI, allowing full control over rendering:
// TanStack Table: headless logic, you provide the UI
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// You render however you want
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
</table>
);
Type Safety First
All libraries leverage TypeScript generics for end-to-end type safety:
// Type-safe query keys and return types
const { data } = useQuery<User[], Error>({
queryKey: ["users"],
queryFn: fetchUsers,
});
// Type-safe route definitions
const routeTree = rootRoute.addChildren([
indexRoute,
usersRoute.addChildren([userRoute]),
]);
// Type-safe column definitions
const columns: ColumnDef<User>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
];
Framework Adapters
Libraries ship core logic separately from framework bindings:
// Core (framework-agnostic)
import { QueryClient } from "@tanstack/query-core";
// React adapter
import { useQuery } from "@tanstack/react-query";
// Vue adapter
import { useQuery } from "@tanstack/vue-query";
// Solid adapter
import { createQuery } from "@tanstack/solid-query";
TanStack Virtual
Efficiently render large lists by only rendering visible items:
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "400px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
TanStack Form
Type-safe form management with framework adapters:
import { useForm } from "@tanstack/react-form";
import { zodValidator } from "@tanstack/zod-form-adapter";
import { z } from "zod";
function ContactForm() {
const form = useForm({
defaultValues: { name: "", email: "" },
onSubmit: async ({ value }) => {
await submitContact(value);
},
validatorAdapter: zodValidator(),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="name"
validators={{ onChange: z.string().min(1, "Required") }}
children={(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((err) => (
<span key={err}>{err}</span>
))}
</div>
)}
/>
<button type="submit">Submit</button>
</form>
);
}
Combining Libraries
Query + Router (Data Loading)
import { createFileRoute } from "@tanstack/react-router";
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
const userQueryOptions = (id: string) =>
queryOptions({
queryKey: ["users", id],
queryFn: () => fetchUser(id),
});
export const Route = createFileRoute("/users/$userId")({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(userQueryOptions(userId)),
component: UserPage,
});
function UserPage() {
const { userId } = Route.useParams();
const { data: user } = useSuspenseQuery(userQueryOptions(userId));
return <h1>{user.name}</h1>;
}
Query + Table (Server-Side Data)
function UsersTable() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const { data } = useQuery({
queryKey: ["users", pagination],
queryFn: () => fetchUsers(pagination),
});
const table = useReactTable({
data: data?.rows ?? [],
columns,
rowCount: data?.totalCount,
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
});
return <DataTable table={table} />;
}
Best Practices
- Leverage headless architecture for full control over UI and styling
- Use TypeScript generics to get end-to-end type safety across all libraries
- Combine TanStack Query with Router for data loading at the route level
- Use TanStack Virtual for lists with more than 100 items
- Use
queryOptions()factory for reusable, type-safe query configurations - Prefer
useSuspenseQuerywith route-level loaders for seamless data flow - Use
manualPagination,manualSorting, andmanualFilteringfor server-driven tables - Keep core logic framework-agnostic; use adapters for React, Vue, or Solid
- Use
overscanin virtualizers to pre-render items for smoother scrolling - Pin TanStack library versions together to avoid compatibility issues
