pgTable/Guides/Sorting and filtering
Guides~7 min

Sorting and filtering

Sort and filter are two more state slices. Where they live matters more than what they hold: client-side, the component derives the visible rows from tableApi.useWatchData() + tableApi.useWatchState(key) through a useMemo; server-side, state-listeners turn slice changes into requests. The control surface is the same either way.

Moderate-tier config

Client-side sort and filter don't need a table-hook — there's no orchestration to crowd the render. The moderate-table tier splits the table-config from the table-component: shape lives in <name>-table-config.ts, the component owns usePgTable and renders.

TScharacters/characters-table-config.ts
// table-config — declared once via pgt.tableState.
import z from 'zod'
import { type PgTableRow, pgt } from '@westopp/pgtable'

export type CharactersColumnMeta = { label: string; sortable: boolean }

export const charactersColumns = pgt.tableColumns<CharactersColumnMeta>()({
id:     { type: z.string(),  label: 'ID',     sortable: false },
name:   { type: z.string(),  label: 'Name',   sortable: true  },
role:   { type: z.string(),  label: 'Role',   sortable: true  },
active: { type: z.boolean(), label: 'Active', sortable: false },
})

export type Character = PgTableRow<typeof charactersColumns>

export const charactersState = pgt.tableState(
{
  sort:    z.object({ by: z.string().nullable(), direction: z.enum(['asc', 'desc']) }),
  filters: z.object({ query: z.string(), roles: z.array(z.string()), activeOnly: z.boolean() }),
},
{
  sort:    { by: 'name', direction: 'asc' },
  filters: { query: '', roles: [], activeOnly: false },
},
)

Client-side: derive in the composition-component

If the dataset fits in memory, sort and filter are pure functions of data + state. Subscribe with the watch hooks, derive the visible list inside useMemo, and hand it down to a render-component. The memo recomputes when rows or either slice changes — not on every render.

TSXcharacters/characters-table.tsx
// Composition-component. Owns usePgTable, subscribes via watch
// hooks, derives the visible list, hands it to the render-component.
import { useMemo } from 'react'
import { type PgTableApi, usePgTable } from '@westopp/pgtable'
import { type Character, charactersColumns, charactersState } from './characters-table-config'

type InnerProps = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> }

const CharactersInner = ({ tableApi }: InnerProps) => {
const rows    = tableApi.useWatchData()
const sort    = tableApi.useWatchState('sort')
const filters = tableApi.useWatchState('filters')

const visible = useMemo(() => {
  const filtered = rows.filter((r) => {
    if (filters.activeOnly && !r.active) return false
    if (filters.roles.length && !filters.roles.includes(r.role)) return false
    if (filters.query && !r.name.toLowerCase().includes(filters.query.toLowerCase())) return false
    return true
  })
  if (!sort.by) return filtered
  const dir = sort.direction === 'asc' ? 1 : -1
  return [...filtered].sort((a, b) => {
    const av = a[sort.by as keyof Character]
    const bv = b[sort.by as keyof Character]
    return av < bv ? -1 * dir : av > bv ? 1 * dir : 0
  })
}, [rows, sort, filters])

// …render header (calls onSort), filter panel (calls onFilters), and visible rows.
}

export const CharactersTable = () => {
const { tableApi } = usePgTable(charactersColumns, charactersState, {
  pgTableId: 'characters',
})
return <CharactersInner tableApi={tableApi} />
}

The sort header

A three-state header (asc → desc → cleared) is one immer updater. Read the current sort slice reactively so the arrow re-renders when the column becomes active or inactive. The write goes through tableApi.setState.

TSXcharacters/characters-sort-header.tsx
// Click a header to sort. Three-state: asc → desc → cleared.
type HeaderProps = {
tableApi: PgTableApi<typeof charactersColumns, typeof charactersState>
columnKey: 'name' | 'role'
label: string
}

export const SortableHeader = ({ tableApi, columnKey, label }: HeaderProps) => {
const sort = tableApi.useWatchState('sort')
const isActive = sort.by === columnKey

const onSort = () => {
  tableApi.setState((prev) => {
    if (!isActive) {
      prev.sort.by = columnKey
      prev.sort.direction = 'asc'
    } else if (prev.sort.direction === 'asc') {
      prev.sort.direction = 'desc'
    } else {
      prev.sort.by = null
      prev.sort.direction = 'asc'
    }
  })
}

const arrow = isActive ? (sort.direction === 'asc' ? ' ↑' : ' ↓') : ''
return <button onClick={onSort}>{label}{arrow}</button>
}

Server-side: promote to a table-hook

For large datasets every slice change is a request, the listeners need a shared refetch, and pagination has to reset on filter changes. That orchestration crowds the component — the right move is to promote to the complex-table tier with named-table-state-listeners. See Server pagination for the full layout; the listener shapes look like the snippet below.

TScharacters/characters-table-listeners.ts
// Named-table-state-listeners for the server-side case.
// Multi-key updates and a fetch trigger — well past the trivial threshold.
import { getTable } from '@westopp/pgtable'
import { CHARACTERS_TABLE_ID, type charactersColumns, type charactersState } from './characters-table-config'

export type CharactersListenerCtx = { refetch: () => Promise<void> }

const getCharactersApi = () => getTable<typeof charactersColumns, typeof charactersState>(CHARACTERS_TABLE_ID)

export const onFiltersChange = (ctx: CharactersListenerCtx) => () => {
const api = getCharactersApi()
if (!api) return
// Reset to page 1 — otherwise the user lands on page 7 of a 3-page filtered result.
api.setState((prev) => { prev.pagination.page = 1 })
void ctx.refetch()
}

export const onSortChange       = (ctx: CharactersListenerCtx) => () => { void ctx.refetch() }
export const onPaginationChange = (ctx: CharactersListenerCtx) => () => { void ctx.refetch() }

Debounce upstream, not downstream

For a search input, debounce the value before it lands in the store. Writing every keystroke into state.filters.query would fire the listener on every keystroke; debouncing on the input side means the store sees one commit per finished search.

TSXcharacters/characters-search-box.tsx
// Debounce on the input side, not in the listener. The store commits exactly
// what you write — debounce upstream so the listener fires once per finished
// search, not once per keystroke.
type Props = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> }

export const SearchBox = ({ tableApi }: Props) => {
const filters  = tableApi.useWatchState('filters')
const [local, setLocal] = useState(filters.query)

useEffect(() => {
  const id = setTimeout(() => {
    if (local === tableApi.getState('filters').query) return
    tableApi.setState((prev) => { prev.filters.query = local })
  }, 250)
  return () => clearTimeout(id)
}, [local, tableApi])

return <input value={local} onChange={(e) => setLocal(e.target.value)} />
}