Skip to content

Datatable, query params, and IndexedDB

reactstate-managementperformance

After eight years of building frontend applications, datatables remain one of those deceptively complex UI patterns. They look simple — rows and columns — but the moment you need filters, sorting, pagination, column reordering, and persistence, you're juggling more state than a Redux store at a hackathon.

This article walks through a pattern I landed on after wrestling with a real production requirement: a datatable whose filter/sort/page state is shareable via URL, while heavier personal preferences (column visibility, column order) persist silently in IndexedDB. The combination gives you the best of both worlds — collaboration through links and comfort through remembered preferences.

The two buckets of datatable state

Not all datatable state is created equal. Some of it is shareable — a colleague sends you a link filtered to "status=active&sort=created_at:desc" and you see exactly what they see. Other state is personal — your preferred column order, which columns you've hidden, maybe your preferred page size.

Cramming everything into query params creates ugly, fragile URLs. Storing everything in IndexedDB makes your table un-linkable. The trick is splitting state into the right bucket:

Query params (shareable, bookmarkable):

  • Filters (search term, status, date range)
  • Sorting (column + direction)
  • Pagination (page number, page size)

IndexedDB (personal, persistent, heavy):

  • Column visibility
  • Column order
  • Column widths
  • Row selection (if applicable)

Setting up: TanStack Table + nuqs + idb

We'll use three libraries that each do one thing well. TanStack Table for headless table logic, nuqs for type-safe query param state, and idb for a Promise-based IndexedDB wrapper.

npm install @tanstack/react-table nuqs idb

Step 1: Type-safe query param parsers with nuqs

nuqs gives us useQueryState hooks that parse and serialize URL params with full type safety. Here we define parsers for the state we want in the URL.

// hooks/use-table-params.ts
import {
  parseAsString,
  parseAsInteger,
  parseAsStringEnum,
  useQueryStates,
} from 'nuqs'

export type SortDirection = 'asc' | 'desc'

const tableParamsParsers = {
  search: parseAsString.withDefault(''),
  status: parseAsStringEnum(['all', 'active', 'inactive']).withDefault('all'),
  sortBy: parseAsString.withDefault('createdAt'),
  sortDir: parseAsStringEnum<SortDirection>(['asc', 'desc']).withDefault('desc'),
  page: parseAsInteger.withDefault(1),
  pageSize: parseAsInteger.withDefault(20),
}

export function useTableParams() {
  return useQueryStates(tableParamsParsers, {
    history: 'push',
    shallow: false, // triggers server re-fetch in Next.js App Router
  })
}

useQueryStates bunches multiple params into a single hook. Updating search and resetting page to 1 happens in one URL push — no double renders, no intermediate states.

Step 2: IndexedDB for column preferences

IndexedDB is overkill for a single key-value pair, but column preferences can be complex — ordered arrays of objects with visibility flags and widths. Unlike localStorage, IndexedDB handles structured data without JSON.stringify gymnastics and won't block the main thread on large reads.

// lib/table-preferences.ts
import { openDB, type IDBPDatabase } from 'idb'

export interface ColumnPreference {
  id: string
  visible: boolean
  width: number | undefined
  order: number
}

const DB_NAME = 'table-preferences'
const DB_VERSION = 1
const STORE_NAME = 'preferences'

let dbPromise: Promise<IDBPDatabase> | null = null

function getDB() {
  if (!dbPromise) {
    dbPromise = openDB(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains(STORE_NAME)) {
          db.createObjectStore(STORE_NAME)
        }
      },
    })
  }
  return dbPromise
}

export async function getColumnPreferences(
  tableId: string,
): Promise<ColumnPreference[] | undefined> {
  const db = await getDB()
  return db.get(STORE_NAME, tableId)
}

export async function setColumnPreferences(
  tableId: string,
  preferences: ColumnPreference[],
): Promise<void> {
  const db = await getDB()
  await db.put(STORE_NAME, preferences, tableId)
}

A few things worth noting: getDB() is memoized so we only open the connection once. The tableId parameter means you can reuse this across multiple tables in the same app. And the upgrade callback only runs when the version number bumps — if you need to migrate the schema later, increment DB_VERSION and add migration logic there.

Step 3: The preferences hook with hydration guard

IndexedDB is async. The table renders before preferences load. We need to handle the intermediate state carefully to avoid a flash of default columns snapping into the user's preferred layout.

// hooks/use-column-preferences.ts
import { useState, useEffect, useCallback, useRef } from 'react'
import {
  getColumnPreferences,
  setColumnPreferences,
  type ColumnPreference,
} from '@/lib/table-preferences'

export function useColumnPreferences(
  tableId: string,
  defaultPreferences: ColumnPreference[],
) {
  const [preferences, setPreferences] =
    useState<ColumnPreference[]>(defaultPreferences)
  const [isLoaded, setIsLoaded] = useState(false)
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  // Load from IndexedDB on mount
  useEffect(() => {
    let cancelled = false

    getColumnPreferences(tableId).then((stored) => {
      if (cancelled) return
      if (stored) {
        setPreferences(stored)
      }
      setIsLoaded(true)
    })

    return () => {
      cancelled = true
    }
  }, [tableId])

  // Persist to IndexedDB, debounced to avoid hammering during drag-reorder
  const updatePreferences = useCallback(
    (next: ColumnPreference[]) => {
      setPreferences(next)

      if (debounceRef.current) clearTimeout(debounceRef.current)
      debounceRef.current = setTimeout(() => {
        setColumnPreferences(tableId, next)
      }, 300)
    },
    [tableId],
  )

  return { preferences, updatePreferences, isLoaded }
}

The cancelled flag in the effect cleanup prevents a stale write if the component unmounts before IndexedDB responds. The debounced persist means dragging columns around doesn't trigger dozens of writes — it waits until the user stops.

The isLoaded flag is critical. You can use it to show a skeleton or simply delay rendering the table until preferences are ready:

const { preferences, updatePreferences, isLoaded } =
  useColumnPreferences('users-table', defaultColumns)

if (!isLoaded) return <TableSkeleton />

Step 4: Wiring it together with TanStack Table

Here's where everything converges. The table's sorting and filtering state comes from URL params. Column visibility and order come from IndexedDB. TanStack Table's state prop accepts externally-controlled state, which makes this composition straightforward.

// components/users-table.tsx
'use client'

import { useMemo } from 'react'
import {
  useReactTable,
  getCoreRowModel,
  flexRender,
  type ColumnDef,
  type VisibilityState,
  type SortingState,
} from '@tanstack/react-table'
import { useTableParams } from '@/hooks/use-table-params'
import { useColumnPreferences } from '@/hooks/use-column-preferences'
import type { ColumnPreference } from '@/lib/table-preferences'

interface User {
  id: string
  name: string
  email: string
  status: 'active' | 'inactive'
  role: string
  createdAt: string
}

const allColumns: ColumnDef<User>[] = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'email', header: 'Email' },
  { accessorKey: 'status', header: 'Status' },
  { accessorKey: 'role', header: 'Role' },
  { accessorKey: 'createdAt', header: 'Created' },
]

const defaultColumnPrefs: ColumnPreference[] = allColumns.map((col, i) => ({
  id: (col as { accessorKey: string }).accessorKey,
  visible: true,
  width: undefined,
  order: i,
}))

export function UsersTable({ data }: { data: User[] }) {
  const [params, setParams] = useTableParams()
  const { preferences, updatePreferences, isLoaded } =
    useColumnPreferences('users-table', defaultColumnPrefs)

  // Derive TanStack-compatible state from our two sources
  const sorting: SortingState = useMemo(
    () => [{ id: params.sortBy, desc: params.sortDir === 'desc' }],
    [params.sortBy, params.sortDir],
  )

  const columnVisibility: VisibilityState = useMemo(() => {
    const vis: VisibilityState = {}
    for (const pref of preferences) {
      vis[pref.id] = pref.visible
    }
    return vis
  }, [preferences])

  const columnOrder = useMemo(
    () =>
      [...preferences]
        .sort((a, b) => a.order - b.order)
        .map((p) => p.id),
    [preferences],
  )

  // Filter data based on URL params
  const filteredData = useMemo(() => {
    let result = data

    if (params.search) {
      const q = params.search.toLowerCase()
      result = result.filter(
        (row) =>
          row.name.toLowerCase().includes(q) ||
          row.email.toLowerCase().includes(q),
      )
    }

    if (params.status !== 'all') {
      result = result.filter((row) => row.status === params.status)
    }

    return result
  }, [data, params.search, params.status])

  const table = useReactTable({
    data: filteredData,
    columns: allColumns,
    state: {
      sorting,
      columnVisibility,
      columnOrder,
    },
    onSortingChange: (updater) => {
      const next =
        typeof updater === 'function' ? updater(sorting) : updater
      if (next.length > 0) {
        setParams({
          sortBy: next[0].id,
          sortDir: next[0].desc ? 'desc' : 'asc',
          page: 1, // reset page on sort change
        })
      }
    },
    onColumnVisibilityChange: (updater) => {
      const next =
        typeof updater === 'function'
          ? updater(columnVisibility)
          : updater

      const updated = preferences.map((pref) => ({
        ...pref,
        visible: next[pref.id] ?? pref.visible,
      }))
      updatePreferences(updated)
    },
    onColumnOrderChange: (updater) => {
      const next =
        typeof updater === 'function' ? updater(columnOrder) : updater

      const updated = preferences.map((pref) => ({
        ...pref,
        order: next.indexOf(pref.id),
      }))
      updatePreferences(updated)
    },
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    manualSorting: true,
  })

  if (!isLoaded) {
    return <div className="h-96 animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800" />
  }

  return (
    <div>
      {/* Search + Filter bar */}
      <div className="mb-4 flex gap-3">
        <input
          type="text"
          placeholder="Search by name or email..."
          value={params.search}
          onChange={(e) => setParams({ search: e.target.value, page: 1 })}
          className="rounded-md border border-zinc-300 px-3 py-2 text-sm
                     dark:border-zinc-700 dark:bg-zinc-800"
        />
        <select
          value={params.status}
          onChange={(e) =>
            setParams({ status: e.target.value as 'all' | 'active' | 'inactive', page: 1 })
          }
          className="rounded-md border border-zinc-300 px-3 py-2 text-sm
                     dark:border-zinc-700 dark:bg-zinc-800"
        >
          <option value="all">All statuses</option>
          <option value="active">Active</option>
          <option value="inactive">Inactive</option>
        </select>
      </div>

      {/* Table */}
      <div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
        <table className="w-full text-left text-sm">
          <thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    onClick={header.column.getToggleSortingHandler()}
                    className="cursor-pointer px-4 py-3 font-medium text-zinc-600 dark:text-zinc-400"
                  >
                    {flexRender(header.column.columnDef.header, header.getContext())}
                    {{ asc: ' ↑', desc: ' ↓' }[
                      header.column.getIsSorted() as string
                    ] ?? ''}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr
                key={row.id}
                className="border-b border-zinc-100 dark:border-zinc-800"
              >
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="mt-4 flex items-center justify-between text-sm text-zinc-600 dark:text-zinc-400">
        <span>
          Page {params.page} · {filteredData.length} results
        </span>
        <div className="flex gap-2">
          <button
            onClick={() => setParams({ page: Math.max(1, params.page - 1) })}
            disabled={params.page <= 1}
            className="rounded border px-3 py-1 disabled:opacity-40
                       dark:border-zinc-700"
          >
            Previous
          </button>
          <button
            onClick={() => setParams({ page: params.page + 1 })}
            className="rounded border px-3 py-1 dark:border-zinc-700"
          >
            Next
          </button>
        </div>
      </div>
    </div>
  )
}

The key insight here is the onSortingChange / onColumnVisibilityChange / onColumnOrderChange callbacks. TanStack Table treats these as controlled state — when the user clicks a header to sort, TanStack doesn't internally update anything. It calls our handler, we push to the URL or IndexedDB, and the table re-renders from the new external state. This is the same controlled-component pattern you'd use with a form input, just applied to table state.

The hydration dance

There's a subtle ordering problem. On initial page load:

  1. URL query params are available immediately (SSR-compatible via nuqs)
  2. IndexedDB preferences are async — they arrive a tick later

This means sorting and filtering work on the very first render (they come from the URL), but column preferences need a loading state. That's actually the correct UX — the user sees their filtered data immediately, and columns settle into place almost instantly after. The skeleton placeholder during !isLoaded prevents layout shift.

If you wanted to be aggressive about perceived performance, you could cache the last-known preferences in a cookie or localStorage as a "preview hint" and use IndexedDB as the source of truth:

// On save, also write a preview to localStorage
localStorage.setItem(`table-preview:${tableId}`, JSON.stringify(preferences))

// On mount, use localStorage preview as initial state
const preview = localStorage.getItem(`table-preview:${tableId}`)
const [preferences, setPreferences] = useState<ColumnPreference[]>(
  preview ? JSON.parse(preview) : defaultPreferences,
)

This eliminates the loading flash entirely for return visitors while keeping IndexedDB as the durable store.

Resetting state

One thing that's easy to overlook: users need a way to reset. With state split across two persistence layers, a "reset" button needs to clear both:

function handleReset() {
  // Reset URL params to defaults
  setParams({
    search: '',
    status: 'all',
    sortBy: 'createdAt',
    sortDir: 'desc',
    page: 1,
    pageSize: 20,
  })

  // Reset column preferences to defaults
  updatePreferences(defaultColumnPrefs)
}

You could also expose a "Copy link" button that only includes the URL params — when someone opens that link, they get the shared filters but their own column preferences.

Why not just localStorage?

I chose IndexedDB over localStorage for a few reasons:

  1. Structured data — column preferences are arrays of objects. localStorage forces you to serialize everything to strings. IndexedDB stores structured clones natively.
  2. Storage limits — localStorage caps at ~5-10MB across the entire origin. IndexedDB gives you hundreds of megabytes. This matters if your app has dozens of configurable tables.
  3. Non-blocking — localStorage is synchronous and blocks the main thread on read/write. IndexedDB is async. For a snappy table interaction, this matters.
  4. Transactional — if you're writing column order and visibility together, IndexedDB guarantees atomicity. localStorage doesn't.

That said, if your use case is a single table with five columns, localStorage is fine. Use the tool that matches the complexity.

Gotchas I ran into

nuqs and App Router caching. If you use shallow: true (the default), nuqs won't trigger a server re-fetch when params change. That's fine for client-side filtering but breaks if your data comes from a server component. Set shallow: false when your data fetching depends on the URL.

IndexedDB in SSR. IndexedDB doesn't exist on the server. The useEffect in useColumnPreferences handles this naturally since effects only run on the client. But if you try to call getColumnPreferences outside a component (e.g., in a server action), it'll throw. Guard with a typeof window !== 'undefined' check if needed.

Column order and new columns. When you deploy a new column that isn't in the user's stored preferences, it won't appear. Your preferences hook needs merge logic — take the stored preferences, add any missing columns from the default list, and remove any stored columns that no longer exist:

function mergePreferences(
  stored: ColumnPreference[],
  defaults: ColumnPreference[],
): ColumnPreference[] {
  const storedIds = new Set(stored.map((p) => p.id))
  const defaultIds = new Set(defaults.map((p) => p.id))

  // Keep stored preferences for columns that still exist
  const merged = stored.filter((p) => defaultIds.has(p.id))

  // Add new columns that aren't in stored preferences
  const newColumns = defaults
    .filter((p) => !storedIds.has(p.id))
    .map((p, i) => ({ ...p, order: merged.length + i }))

  return [...merged, ...newColumns]
}

Debouncing URL updates. If your search input pushes to the URL on every keystroke, you'll flood the browser history. nuqs throttles URL updates by default (50ms, 120ms on Safari) and provides a limitUrlUpdates option for custom control, but you can also debounce at the input level.

Wrapping up

The pattern is simple once you see it: URL for shareable state, IndexedDB for personal state, TanStack Table as the controlled orchestrator. Each layer has a single responsibility, and the table component just maps between them.

This approach has served me well in production dashboards where the same table is used by dozens of team members, each with their own column preferences, but all sharing filtered views via Slack links. The URL tells you what you're looking at. IndexedDB remembers how you like to look at it.