# Sorting

Ordered multi-column sort: header cycles, priority badges, controlled state, and typed row-model queries.


Every column is sortable **by default**. Click a header to sort by that column; **shift-click** other headers to build an ordered multi-column cascade — later keys break ties left by earlier ones. Set `sortable: false` on a column to opt it out: clicks and shift-clicks on it are no-ops.

### Example: Multi-column sort cascade

Click a header to sort by it, shift-click others to build an ordered cascade — each sorted header grows a priority badge, and shift-clicking a middle key back to unsorted renumbers the rest.

Source: https://pretable.ai/examples/multi-column-sort.md

```tsx MultiColumnSortGrid.tsx
"use client";

import { useState, type ComponentProps } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { orders } from "./data";

const VIEWPORT_HEIGHT = 340;

export function MultiColumnSortGrid() {
  // The complete query is controlled so the ordered sort list can be echoed
  // below the grid, in lockstep with the priority badges the headers render
  // themselves. Omit both query props to let rows mode own sorting instead.
  const [query, setQuery] = useState<
    NonNullable<
      ComponentProps<typeof PretableSurface<(typeof orders)[number]>>["query"]
    >
  >({
    filters: [],
    sort: [],
    rowGroups: [],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Click <strong>Status</strong> to sort by it. <kbd>Shift</kbd>+click{" "}
        <strong>Region</strong>, then <kbd>Shift</kbd>+click{" "}
        <strong>Total</strong> — each sorted header grows a priority badge. Now{" "}
        <kbd>Shift</kbd>+click <strong>Region</strong> twice more (desc → asc →
        removed) and watch <strong>Total</strong>&rsquo;s badge renumber from 3
        to 2.
      </p>
      <PretableSurface
        ariaLabel="Orders"
        columns={columns}
        getRowId={(row) => row.id}
        onQueryChange={setQuery}
        query={query}
        rows={orders}
        viewportHeight={VIEWPORT_HEIGHT}
      />
      <p style={{ margin: "8px 0 0", fontSize: 13 }}>
        Sort:{" "}
        <code>
          {query.sort.length > 0
            ? query.sort
                .map(
                  (entry, index) =>
                    `${index + 1}. ${entry.columnId} ${entry.direction}`,
                )
                .join(" · ")
            : "(unsorted)"}
        </code>
      </p>
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Order } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

export const columns: PretableColumn<Order>[] = [
  { id: "customer", header: "Customer" },
  { id: "region", header: "Region" },
  { id: "status", header: "Status" },
  {
    id: "total",
    header: "Total",
    type: "number",
    format: ({ value }) => usd.format(value as number),
  },
];
```

```ts data.ts
export interface Order {
  id: string;
  customer: string;
  region: string;
  status: string;
  total: number;
}

/**
 * Deliberately ties on `region` and `status` across several rows — a
 * single-column sort leaves those ties in source order, but adding `total`
 * as a second or third key breaks them, which is the whole point of a
 * shift-click cascade.
 */
export const orders: Order[] = [
  {
    id: "o1",
    customer: "Acme Co",
    region: "East",
    status: "Open",
    total: 4200,
  },
  {
    id: "o2",
    customer: "Bilt LLC",
    region: "West",
    status: "Shipped",
    total: 1800,
  },
  {
    id: "o3",
    customer: "Croma Inc",
    region: "East",
    status: "Open",
    total: 2600,
  },
  {
    id: "o4",
    customer: "Delta Bros",
    region: "Central",
    status: "Closed",
    total: 9100,
  },
  {
    id: "o5",
    customer: "Ester Group",
    region: "West",
    status: "Open",
    total: 3400,
  },
  {
    id: "o6",
    customer: "Foxglove",
    region: "East",
    status: "Shipped",
    total: 5300,
  },
  {
    id: "o7",
    customer: "Grove & Co",
    region: "Central",
    status: "Open",
    total: 1200,
  },
  {
    id: "o8",
    customer: "Halden Ltd",
    region: "West",
    status: "Shipped",
    total: 7600,
  },
  {
    id: "o9",
    customer: "Ionix",
    region: "East",
    status: "Closed",
    total: 3300,
  },
  {
    id: "o10",
    customer: "Juno Retail",
    region: "Central",
    status: "Open",
    total: 6700,
  },
  {
    id: "o11",
    customer: "Kestrel",
    region: "West",
    status: "Closed",
    total: 2100,
  },
  {
    id: "o12",
    customer: "Lumen Data",
    region: "Central",
    status: "Shipped",
    total: 4800,
  },
];
```


## The sort model

Sort state is an **ordered list of entries**, not a single column:

```ts
interface PretableSortEntry {
  columnId: string;
  direction: "asc" | "desc";
}

// query.sort and snapshot.query.sort are both:
type Sort = PretableSortEntry[];
```

- **Index = priority.** `sort[0]` is the primary key, `sort[1]` breaks its ties, and so on.
- **`[]` means unsorted** — rows appear in source order.
- **Sorting is stable.** Rows tied on every key keep their source order.
- **Comparison keys on the values.** When every cell value in a column is a number, the column compares numerically; otherwise values are stringified and compared locale-aware (numeric-aware, case-insensitive).
- Unknown column ids are rejected with a typed `invalid-query` error. The surface never emits sort entries for `sortable: false` columns; clicks on those headers are no-ops.

Under [row grouping](/docs/grid/grouping#sorting-under-grouping) the sort is applied to the whole row set first and then bucketed, so rows sort **within** their group; sibling groups order by their key value, ascending unless a sort entry on that level's column says otherwise.

## Header interactions

**Plain click** cycles a single-column sort: unsorted → `desc` → `asc` → unsorted. It always **replaces** the whole list — clicking a header while a multi-column cascade is active collapses the sort to just that column's next cycle step. (Edge case: if that column was already `asc`, the next step is unsorted, so the whole sort clears.)

**Shift-click** edits the list in place, one column at a time — this is the gesture the example above walks you through:

- On an **unsorted column**: appends `{ columnId, direction: "desc" }` to the end of the list (lowest priority).
- On a column already sorted **`desc`**: flips that entry to `asc` without moving it.
- On a column already sorted **`asc`**: removes just that entry — the other entries keep their relative order, and their priorities renumber.

So shift-click walks the same desc → asc → none cycle as a plain click, scoped to one entry of the list.

**By keyboard**, both gestures are the same two keys on a focused header: `↑` from the first data row puts the cursor on that column's header, and `Enter` or `Space` there is the plain click while `Shift + Enter` is the shift-click. The header cell is a real `<button>`, so these run its native activation — the identical `onClick` and therefore the identical cycles above, not a parallel implementation. See [Keyboard § The column header](/docs/grid/keyboard#the-column-header).

### Priority badges

When two or more columns are sorted, each sorted header shows its 1-based priority next to the direction indicator — the small number that appeared on **Status**, then **Region**, then **Total** as you built the cascade above, and that renumbered when a middle entry was removed. The badge is a `<span data-pretable-sort-priority>` whose text is the priority number — style or query it via that attribute. With a single sorted column no badge renders.

Each sorted header also carries `aria-sort="ascending"` or `"descending"` (`"none"` otherwise), keyed to that column's own entry.

## Controlling the query

Sort is uncontrolled when the query pair is omitted. To seed, observe, or persist it, own the complete typed query and publish every returned query — the example above does exactly this, which is how its caption can echo the live `sort` array as you click:

```tsx
import { useState } from "react";
import type { PretableQueryFor } from "@pretable/core";
import { PretableSurface } from "@pretable/react";

function OrdersGrid() {
  const [query, setQuery] = useState<PretableQueryFor<typeof columns>>({
    filters: [],
    sort: [
      { columnId: "status", direction: "asc" },
      { columnId: "total", direction: "desc" },
    ],
    rowGroups: [],
  });

  return (
    <PretableSurface
      ariaLabel="Orders"
      columns={columns}
      rows={rows}
      getRowId={(row) => row.id}
      query={query}
      onQueryChange={setQuery}
      viewportHeight={360}
    />
  );
}
```

`sort: []` is explicitly unsorted. To return ownership to the local row model, omit both `query` and `onQueryChange`. Explicit-model mode changes sorting through `rowModel.setQuery(...)`, not surface props.

## Headless

Sorting is one slice of the row model's typed query:

```ts
const snapshot = rowModel.getState().snapshot;
const transition = rowModel.setQuery({
  ...snapshot.query,
  sort: [
    { columnId: "status", direction: "asc" },
    { columnId: "total", direction: "desc" },
  ],
});
await transition.finished;
```

The transition publishes the new order atomically. Use `sort: []` to restore source order, and read the required output window with `snapshot.range(start, end)`. See the [headless API reference](/docs/headless/api-reference).

## See also

- [Filtering](/docs/grid/filtering) — the neighboring header interaction; sort applies after filters.
- [Row grouping and aggregation](/docs/grid/grouping) — group ordering and sorting within groups.
- [`<PretableSurface>`](/docs/grid/pretable-surface) — the `state` prop and the controlled/uncontrolled pattern.
- [Row grouping](/docs/grid/grouping#sorting-under-grouping) — how sort and group order interact.
- [API reference](/docs/grid/api-reference) — typed query and controlled surface state.
