State, Identity, and Workflows

Table state and domain workflow state often meet in the same UI, but they should not be modeled as the same thing. Use table state for table features. Use domain state, query state, mutation state, or local workflow state for product behavior.

Keep Domain Workflow State Outside the Table

Concern

Some state is table state: sorting, filtering, selection, expansion, visibility, and other features exposed by the table. Other state is domain workflow state: pending mutations, modal state, validation errors, draft workflows, permission checks, current user context, or query-specific filters.

It is common for table interactions to drive an external data source. Sorting may map to backend field names, filters may map to query parameters, and row actions may map to mutation state. That does not mean the table should own the entire data workflow.

Pitfall

Controlled column filter state is valid when it represents actual column filters. The pitfall is using ColumnFiltersState as a generic query parameter bag for values that are not table column filters.

const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([
  {id: "status", value: "open"},
  {id: "includeArchived", value: false},
  {id: "mineOnly", value: true},
])

This makes non-column concerns look like column filters. status may be a real column filter, but includeArchived and mineOnly are product query state unless they map to filterable columns in the table.

When a wrapper treats every backend query option as table state, it creates a second state model on top of the table. The wrapper has to synchronize query mapping, fetching, loading, toolbar behavior, row actions, and rendering with table feature state. That wrapper usually grows because each backend and product workflow is slightly different.

Preferred Pattern

Use built-in table state for table features. Use route, component, query, mutation, or domain state for workflow concerns that do not map cleanly to table features.

const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([
  {id: "status", value: "open"},
])
const [sorting, setSorting] = useState<SortingState>([])
const [includeArchived, setIncludeArchived] = useState(false)
const [mineOnly, setMineOnly] = useState(true)

const query = useTicketsQuery(
  ticketTableQuery({
    columnFilters,
    sorting,
    includeArchived,
    mineOnly,
  }),
)

const table = useReactTable({
  columns,
  data: query.data ?? [],
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  manualFiltering: true,
  manualSorting: true,
  state: {
    columnFilters,
    sorting,
  },
  onColumnFiltersChange: setColumnFilters,
  onSortingChange: setSorting,
})

The reusable part is the translator between table/domain state and the backend contract:

function ticketTableQuery({
  columnFilters,
  sorting,
  includeArchived,
  mineOnly,
}: {
  columnFilters: ColumnFiltersState
  sorting: SortingState
  includeArchived: boolean
  mineOnly: boolean
}) {
  return {
    filters: columnFilters.map(toTicketFilter),
    sort: sorting.map(toTicketSort),
    includeArchived,
    mineOnly,
  }
}

For frequently changing row or cell workflow state, use typed cell components that read from a domain provider:

<TicketActionsProvider>
  <TicketsTable data={tickets} />
</TicketActionsProvider>
const columns = useMemo<ColumnDef<Ticket>[]>(
  () => [
    columnHelper.display({
      id: "actions",
      cell: ({row}) => <TicketActionsCell ticket={row.original} />,
    }),
  ],
  [],
)

function TicketActionsCell({ticket}: {ticket: Ticket}) {
  const isPending = useIsTicketPending(ticket.id)
  const actions = useTicketActions()

  return (
    <TicketActions
      disabled={isPending}
      onClose={() => actions.close(ticket.id)}
      ticket={ticket}
    />
  )
}

Or subscribe directly to query or mutation cache state:

function TicketActionsCell({ticket}: {ticket: Ticket}) {
  const isClosing = useIsMutating({
    mutationKey: ["ticket", ticket.id, "close"],
  })

  return <TicketActions disabled={isClosing > 0} ticket={ticket} />
}

Use table.options.meta mainly for stable callbacks or adapters. Do not put frequently changing row or cell values in meta just because cells need them.

Keep Row Identity Stable

Concern

Row index is always available and can look sufficient in static examples. It is tempting to use row.index for edit maps, pending rows, selection side effects, or action state because the value is already present in the cell context.

Pitfall

row.index is not stable identity. Index-keyed state can attach to the wrong record after sorting, filtering, refetching, insertion, deletion, grouping, expansion, or editing.

const [editedValues, setEditedValues] = useState<
  Record<number, Partial<User>>
>({})

const columns = useMemo<ColumnDef<User>[]>(
  () => [
    columnHelper.accessor("status", {
      header: "Status",
      cell: ({row}) => (
        <StatusSelect
          value={editedValues[row.index]?.status ?? row.original.status}
          onValueChange={(status) =>
            setEditedValues((values) => ({
              ...values,
              [row.index]: {...values[row.index], status},
            }))
          }
        />
      ),
    }),
  ],
  [editedValues],
)

Preferred Pattern

Use durable domain IDs through getRowId, then key external row state by row.id. This should map to a unique identifier in your data objects.

Keep the column definition static. Pass stable row identity and the current cell value to a typed cell component, then let that component read and update edit workflow state.

type EditedUserValues = Record<string, Partial<User>>

const columns = [
  columnHelper.accessor("status", {
    header: "Status",
    cell: ({getValue, row}) => (
      <EditableStatusCell rowId={row.id} value={getValue()} />
    ),
  }),
] satisfies ColumnDef<User>[]

function UsersTable({data}: {data: User[]}) {
  const [editedValues, setEditedValues] = useState<EditedUserValues>({})

  const table = useReactTable({
    columns,
    data,
    getCoreRowModel: getCoreRowModel(),
    getRowId: (row) => row.id,
  })

  return (
    <UserEditProvider
      editedValues={editedValues}
      setEditedValues={setEditedValues}
    >
      <UsersTableView table={table} />
    </UserEditProvider>
  )
}

function EditableStatusCell({
  rowId,
  value,
}: {
  rowId: string
  value: User["status"]
}) {
  const {editedValues, setEditedValues} = useUserEditState()
  const status = editedValues[rowId]?.status ?? value

  return (
    <StatusSelect
      value={status}
      onValueChange={(nextStatus) =>
        setEditedValues((values) => ({
          ...values,
          [rowId]: {...values[rowId], status: nextStatus},
        }))
      }
    />
  )
}

In this example, editedValues belongs to the edit workflow provider and is keyed by row.id. It is not an input to columns.

Use row.index only when position itself is the displayed value. For grouped or expanded rows, be explicit about whether state belongs to the generated row or the original domain record.

Last updated on by Ryan Bower