When working with TanStack Table, filtering over nested data, we are faced with a unique issue. About 9 out of 10 times, our data for tables comes directly from a backend API, and while the table’s default behavior is simple for flat structures, handling deeply nested properties works differently.
Let's look through this code first
Imagine we are fetching from our api, the goal is to populate our table with customers payment data
We can write our type based on the response
export type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
//then we define our columns
export const columns: ColumnDef<Payment>[] = [
{
accessorKey: "status",
header: "Status",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "amount",
header: "Amount",
},
];
We can write the ui using the table properties, and then add a search:
<Input
placeholder="Filter nin status..."
value={
(table.getColumn("nin-status")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("nin-status")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
Still simple and straightforward but our challenge lies in filtering over nested data
Understanding Column IDs in TanStack Table
By default, the column ID is determined as follows:
- accessorKey: Uses the provided key directly as the ID.
{ accessorKey: "status", header: "Status" }
// ID: "status" - accessorFn: Derives the ID from the header (converted to lowercase, spaces replaced with underscores).
{accessorFn: (row) => row.creator?.email header: "Creator Email" // ID: "creator_email"}
- Explicit id: Uses the manually provided ID.
{ id: "nin_status", header: "NIN Status" }
// ID: "nin_status" So accessorKey > accessorFn > id
- accessorKey: Uses the provided key as the ID.
- accessorFn: Defaults to deriving the ID from the header (typically lowercased with underscores) unless an ID is explicitly provided.
- Explicit id: Always uses the provided ID, giving you full control.
The Challenge: Nested Data
When fetching data from a backend, you might deal with nested objects. For example, consider the following type:
export type CustomerType = {
id: number;
token: string;
name: string;
created_at: string;
status: string;
creator?: {
email: string | null;
phone_number: string | null;
bvn_verification_status: string | null;
nin_verification_status: string | null;
paystack_wallet_total: string | null;
first_name: string | null;
last_name: string | null;
} | null;
};
When filtering nested data in TanStack Table, two things to look out for:
- Column IDs ≠ Nested Paths: Filters rely on column IDs, which don’t automatically resolve nested paths like creator.email.
- Dot Notation Traps: Using accessorKey: "creator.email" displays data but breaks filtering (searches for literal row["creator.email"]).
1. Dot Notation Accessor (Simple but Limited)
{
accessorKey: "creator.email", // Auto-flattens data
header: "Creator Email",
}
We might be tempted to use this solution but it doesnt work as well. While dot notation in accessorKey (e.g., "creator.email") displays nested data correctly, TanStack Table’s default filtering doesn’t automatically traverse nested objects. The filter looks for literal top-level keys like "creator.email" in your data (which don’t exist), rather than following the nested path creator → email. You’d need a custom filterFn to handle this (which isn’t set up by default).
2. Accessor Function + Explicit ID (Recommended)
{
id: "nin-status",
accessorFn: (row) => row.creator?.nin_verification_status ?? "",
header: "Status",
cell: ({ row }) => {
return <div>{row.original.creator?.nin_verification_status}</div>;
},
},
//then filter here
<Input
placeholder="Filter nin status..."
value={
(table.getColumn("nin-status")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("nin-status")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
3. Explicit ID (Simplest)
{
accessorKey: "creator.first_name",
id:"firstname"
header: "Firstname",
},
//then filter here
<Input
placeholder="Filter nin status..."
value={
(table.getColumn("firstname")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("firstname")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
Conclusion
Filtering nested data in TanStack Table requires understanding how column IDs map to your data structure. While the accessorKey dot notation might seem tempting for nested properties, its filtering limitations make it unsuitable for real-world use cases.
The recommended approach combines explicit column IDs with accessor functions:
- Use id to create predictable filter bindings
- Leverage accessorFn to safely navigate nested objects
- Maintain type safety with TypeScript path helpers For simple cases, explicit IDs with accessorKey provide a quick solution, but complex nested data demands the robustness of accessorFn. Remember: your filter's effectiveness depends on how well your column IDs align with your data access patterns. In my next post, we'll explain how I use dynamic filtering architectures for my tables and how we can use them with nested datasets.
Top comments (0)