Type-Safe Tables: Eliminating Prop Drilling with TanStack Table, React Context and Shadcn/ui
DEC 15, 2025
I've used TanStack Table a lot the past two years, and it is amazing! The headless approach TanStack table gives is unbeatable, and the basic example on the landing page is an excellent starting point.
However, when the table begins to build up it is in my experience that I end up with a lot of prop drilling. So on one of my daily walks I was thinking, what about providing the react-table through a context provider fully typed with TypeScript. That way all nested components would get the table from a context hook, right?
So here is a take on how to implement this, first up let's fire up TanStack Start with all the boilerplate set up, go to you favourite project folder in the terminal and run:
npm create @tanstack/start@latest
In this example we'll use Shadcn, another great library built upon Radix UI/Base UI. So let's install the Table and Input component as well:
npx shadcn@latest add table input
From the basic TanStack Table example, how would we abstract this to a type safe context provider? We need some data and since I'm currently deep into Business Central development, why not create some mock data from the Item table, we need a model to type this json feed.
export type ItemType = 'Inventory' | 'Service' | 'Non-Inventory'
export interface Item {
id: string
number: string
displayName: string
inventory: number
unitPrice: number
type: ItemType
blocked: boolean
unitOfMeasureCode: string
itemCategoryCode: string
lastModifiedDateTime: string
}
Usually when I work with TanStack Table I start off with laying out the column definition, here is a simple config with a subset of the model properties, it uses our Item model as a generic to enable type safety:
import type { ColumnDef } from '@tanstack/react-table'
import type { Item } from '@/@types/item.ts'
export function itemsColumns(): Array<ColumnDef<Item>> {
return [
{
accessorKey: 'number',
header: 'Item No.',
},
{
accessorKey: 'displayName',
header: 'Description',
},
{
accessorKey: 'type',
header: 'Type',
},
{
accessorKey: 'inventory',
header: 'Stock',
cell: ({ row }) => (
<div className="text-right tabular-nums px-4">
{row.original.inventory}
</div>
),
},
{
accessorKey: 'unitPrice',
header: 'Unit Price',
cell: ({ row }) => (
<div className="text-right tabular-nums px-4">
{row.original.unitPrice.toFixed(2)}
</div>
),
},
{
accessorKey: 'itemCategoryCode',
header: 'Category',
},
]
}
So what we want to do is to initialize the table from a context provider so it's easy to utilize the react table instance anywhere in the children stack. Here's what we want to avoid. Without context, you end up passing the table instance to every component:
<TableToolbar table={table} />
<TableFilters table={table} />
<DataTable table={table} />
<TablePagination table={table} />
…and if each of these contains sub-components that also forward the table prop, yeah it will be a lot of code to maintain. What we want to achieve is this…
<DataTableProvider<Item> data={data} columns={itemsColumns()}>
<DataTable<Item> />
{/* Any other component who want to consume the react-table */}
</DataTableProvider>
Now all the components is self-contained and can by themselves decide whether to consume the table or not. Maybe the parent component does not need the table but several children do. Also here we pass down the Item model as a generic type to ensure type safety everywhere in the provider context.
Next, let's create our table provider:
import { createContext, useContext } from 'react'
import {
getCoreRowModel,
getFilteredRowModel,
useReactTable,
} from '@tanstack/react-table'
import type { ReactNode } from 'react'
import type { ColumnDef, Table } from '@tanstack/react-table'
type DataTableContextValue<TData> = {
table: Table<TData>
}
const DataTableContext = createContext<DataTableContextValue<any> | null>(null)
export function DataTableProvider<TData>({
data,
columns,
children,
}: {
data: Array<TData>
columns: Array<ColumnDef<TData>>
children: ReactNode
}) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<DataTableContext.Provider value={{ table }}>
{children}
</DataTableContext.Provider>
)
}
export function useDataTableContext<TData>() {
const context = useContext(DataTableContext)
if (!context) {
throw new Error('useTableContext must be used within TableProvider')
}
// assert the context type here using the generic <TData>
return context as DataTableContextValue<TData>
}
To render this we need an abstract data table, here is a simplistic example where we use the same structure as from the TanStack table landing page together with the Shadcn Table component which we installed:
import { flexRender } from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from '@/components/ui/table.tsx'
import { useDataTableContext } from '@/components/data-table/data-table-provider.tsx'
export function DataTable<TData>() {
const { table } = useDataTableContext<TData>()
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}
The true power of this Context pattern is realized when we create reusable components that manage table state without receiving any props. We can now create a generic ColumnFilter component that can filter any column, simply by specifying the column key.
This component consumes the table instance from the context, ensuring it is fully type-safe based on the TData model you provide. And it can easily be placed anywhere in the component tree, for example, in column headers to enable per-column filtering, or in a toolbar for global search.
import type { ChangeEvent } from 'react'
import { useDataTableContext } from '@/components/data-table/data-table-provider.tsx'
import { Input } from '@/components/ui/input.tsx'
interface ColumnFilterProps<TData> {
column: keyof TData & string
}
export function ColumnFilter<TData>({ column }: ColumnFilterProps<TData>) {
const { table } = useDataTableContext<TData>()
const currentColumn = table.getColumn(column)
const value = currentColumn?.getFilterValue() as string
function handleChange(event: ChangeEvent<HTMLInputElement>) {
currentColumn?.setFilterValue(event.target.value)
}
return (
<Input
value={value}
onChange={handleChange}
placeholder={`Filter ${column}...`}
/>
)
}
Putting it all together
A full route/page usage would look like this:
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import mockItems from 'public/bc-items-mock.json'
import type { Item } from '@/@types/item.ts'
import { DataTableProvider } from '@/components/data-table/data-table-provider.tsx'
import { itemsColumns } from '@/routes/-items-columns.tsx'
import { DataTable } from '@/components/data-table/data-table.tsx'
export const ITEMS_QUERY_KEY = 'items'
export const fetchItems = () => {
// simulating a server call here, this is
// where our a service fetch usually takes place
return mockItems as Array<Item>
}
const itemsQueryOptions = queryOptions({
queryKey: [ITEMS_QUERY_KEY],
queryFn: () => fetchItems(),
})
export const Route = createFileRoute('/')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(itemsQueryOptions),
component: App,
})
function App() {
const { data } = useSuspenseQuery(itemsQueryOptions)
return (
<main className="p-4">
<DataTableProvider<Item> data={data} columns={itemsColumns()}>
<ColumnFilter<Item> column="displayName" />
<DataTable<Item> />
</DataTableProvider>
</main>
)
}
That's it! We've eliminated prop drilling while maintaining full type safety. The pattern scales nicely, you can add pagination, sorting, column visibility, or any other table feature by simply dropping in new components. No prop updates required. Any component within the provider can access the table instance via the useDataTableContext hook, making our components more composable and easier to maintain.
Full source of this example is found here:
Thanks for reading