# Row grouping and aggregation

Group rows by one or more columns, aggregate values, and control expansion.


Grouping folds rows into a tree while keeping one flat, virtualized index underneath. Selection, focus, copy, and streaming therefore keep the same bounded-range behavior they have while ungrouped. Grouping is a query over the row model: enable the panel to let people choose levels, or provide a controlled `query`.

### Example: Drag-to-group panel

The query is controlled here, so dragging a header onto the panel updates both the grid and the level list shown below it.

Source: https://pretable.ai/examples/grouping-panel.md

```tsx GroupingPanelGrid.tsx
"use client";

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

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

import { columns } from "./columns";
import { type Position, positions } from "./data";

const VIEWPORT_HEIGHT = 340;

export function GroupingPanelGrid() {
  // The complete query is controlled so the current grouping levels can be
  // shown outside the grid. Omit both query props to let rows mode own it.
  const [query, setQuery] = useState<
    NonNullable<ComponentProps<typeof PretableSurface<Position>>["query"]>
  >({
    filters: [],
    sort: [],
    rowGroups: [{ columnId: "desk" }],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Drag a header onto the strip to add a level · drag a chip to reorder · ✕
        or <kbd>Delete</kbd> removes one · click a ▾ to collapse a group
      </p>
      <PretableSurface<Position>
        ariaLabel="Positions grouped by desk"
        columns={columns}
        getRowId={(row) => row.id}
        groupPanel={{ enabled: true }}
        onQueryChange={setQuery}
        query={query}
        rows={positions}
        viewportHeight={VIEWPORT_HEIGHT}
      />
      <p style={{ margin: "8px 0 0", fontSize: 13 }}>
        Grouped by:{" "}
        <code>
          {query.rowGroups.length > 0
            ? query.rowGroups.map((entry) => entry.columnId).join(" → ")
            : "(nothing)"}
        </code>
      </p>
    </div>
  );
}
```

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

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

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

const count = new Intl.NumberFormat("en-US");

export const columns: PretableColumn<Position>[] = [
  { id: "desk", header: "Desk" },
  { id: "sector", header: "Sector" },
  { id: "symbol", header: "Symbol" },
  {
    id: "shares",
    header: "Shares",
    type: "number",
    aggregate: "sum",
    format: ({ value }) => count.format(value as number),
    // NOT `format`. `PretableFormatInput.row` is non-optional and a group row
    // has no row behind it, so the cell formatter above is not a legal
    // aggregate formatter — this is the hook that is.
    formatAggregate: ({ value }) =>
      typeof value === "number" ? count.format(value) : "",
  },
  {
    id: "marketValue",
    header: "Market value",
    type: "number",
    aggregate: "sum",
    format: ({ value }) => usd.format(value as number),
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "",
  },
];
```

```ts data.ts
export interface Position {
  id: string;
  desk: string;
  sector: string;
  symbol: string;
  shares: number;
  marketValue: number;
}

/**
 * Deliberately low cardinality on `desk` and `sector` — grouping is only
 * legible when a level has a handful of distinct keys, not hundreds.
 */
export const positions: Position[] = [
  {
    id: "p1",
    desk: "Equities",
    sector: "Technology",
    symbol: "NVDA",
    shares: 4200,
    marketValue: 512_400,
  },
  {
    id: "p2",
    desk: "Equities",
    sector: "Technology",
    symbol: "MSFT",
    shares: 1800,
    marketValue: 748_900,
  },
  {
    id: "p3",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "LLY",
    shares: 620,
    marketValue: 486_100,
  },
  {
    id: "p4",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "UNH",
    shares: 950,
    marketValue: 501_300,
  },
  {
    id: "p5",
    desk: "Equities",
    sector: "Energy",
    symbol: "XOM",
    shares: 3100,
    marketValue: 364_800,
  },
  {
    id: "p6",
    desk: "Credit",
    sector: "Financials",
    symbol: "JPM",
    shares: 2400,
    marketValue: 623_500,
  },
  {
    id: "p7",
    desk: "Credit",
    sector: "Financials",
    symbol: "GS",
    shares: 780,
    marketValue: 419_700,
  },
  {
    id: "p8",
    desk: "Credit",
    sector: "Energy",
    symbol: "CVX",
    shares: 1500,
    marketValue: 238_200,
  },
  {
    id: "p9",
    desk: "Macro",
    sector: "Financials",
    symbol: "TLT",
    shares: 5600,
    marketValue: 497_800,
  },
  {
    id: "p10",
    desk: "Macro",
    sector: "Energy",
    symbol: "USO",
    shares: 8800,
    marketValue: 611_600,
  },
  {
    id: "p11",
    desk: "Macro",
    sector: "Technology",
    symbol: "SMH",
    shares: 1250,
    marketValue: 329_400,
  },
  {
    id: "p12",
    desk: "Macro",
    sector: "Technology",
    symbol: "QQQ",
    shares: 900,
    marketValue: 452_700,
  },
];
```


## Quick start

Use the typed column helper so group keys, aggregate inputs, and aggregate outputs stay tied to the column tuple.

```tsx title="OrdersGrid.tsx"
import { createColumnHelper } from "@pretable/core";
import { PretableSurface } from "@pretable/react";

type Order = {
  id: string;
  region: string;
  owner: string;
  amount: number;
};

const column = createColumnHelper<Order>();
const columns = [
  column.accessor("region", { type: "text", header: "Region" }),
  column.accessor("owner", { type: "text", header: "Owner" }),
  column.accessor("amount", {
    type: "number",
    header: "Amount",
    aggregate: "sum",
    formatAggregate: ({ value }) =>
      value === null ? "—" : `$${value.toLocaleString()}`,
  }),
] as const;

export function OrdersGrid({ rows }: { rows: readonly Order[] }) {
  return (
    <PretableSurface
      ariaLabel="Orders"
      columns={columns}
      getRowId={(row) => row.id}
      groupPanel={{ enabled: true }}
      rows={rows}
      viewportHeight={480}
    />
  );
}
```

Open a column menu and choose **Group by this column**, or drag its header into the panel — the same panel shown in the example above, already grouped by desk. Active levels appear as ordered chips. Drag a chip to reorder levels; its remove button, Delete, or Backspace ungroups it. Arrow Left and Arrow Right reorder a focused chip.

The surface has `role="grid"` while ungrouped and `role="treegrid"` while grouped. The active chip strip is a `listbox`, and column actions use a `menu`. The panel consumes space inside `viewportHeight`, so enabling it does not change the surrounding bezel or page layout.

## Control the grouping query

Rows mode can own the complete query, including grouping. `query` and `onQueryChange` are an exact pair: provide both, or omit both and let the row model own changes from menus, panel drags, sorting, and filtering.

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

function ControlledOrders({ rows }: { rows: readonly Order[] }) {
  const [query, setQuery] = useState<PretableQueryFor<typeof columns>>({
    filters: [],
    sort: [],
    rowGroups: [],
  });

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

`rowGroups: []` is a controlled ungrouped state: user grouping persists only if `onQueryChange` publishes the returned complete query.

For explicit ownership, create a model with the same typed query and pass it to the surface:

```tsx
const rowModel = createLocalRowModel({
  rows,
  columns,
  query: {
    filters: [],
    sort: [],
    rowGroups: [{ columnId: "region", direction: "asc" }],
  },
});

<PretableSurface
  ariaLabel="Orders"
  columns={columns}
  groupPanel={{ enabled: true }}
  model={rowModel}
  viewportHeight={480}
/>;
```

## Indexed group rows

`snapshot.rowAt(index)` and `snapshot.range(start, end)` return a discriminated `"data" | "group"` union. Group entries expose the typed group path, `childCount`, depth, and finalized aggregates. A collapsed group simply omits its descendants from the index; the snapshot never materializes a nested tree or a complete derived-row array.

While grouping is active, the surface derives one tree column for the group label, twisty, and child count. Configure it with `groupColumn`; it is presentation state, not a member of the model's typed column tuple.

## Aggregates and number formatting

Numeric columns support `"sum"`, `"avg"`, `"min"`, `"max"`, and `"count"`; other values support `"count"`. `formatAggregate` receives the inferred aggregate output separately from the leaf-row `format` callback — the panel example above already uses it on **Shares** and **Market value**.

When `formatAggregate` is absent, a numeric aggregate inherits the column's `numberFormat` for display and built-in clipboard output:

```tsx
import { numberFormats } from "@pretable/react";

column.accessor("amount", {
  type: "number",
  aggregate: "sum",
  numberFormat: numberFormats.money({ currency: "USD" }),
});
```

Custom aggregators are mergeable reducers. `init` creates an accumulator, `accumulate` adds one leaf value, `merge` combines partial accumulators, and `finalize` creates the displayed value. Keep `merge` associative and deterministic.

```ts
import type { PretableAggregator } from "@pretable/core";

const weightedAverage: PretableAggregator<
  Order,
  number,
  { weightedTotal: number; weight: number },
  number | null
> = {
  init: () => ({ weightedTotal: 0, weight: 0 }),
  accumulate: (accumulator, value, row) => ({
    weightedTotal: accumulator.weightedTotal + value * row.amount,
    weight: accumulator.weight + row.amount,
  }),
  merge: (left, right) => ({
    weightedTotal: left.weightedTotal + right.weightedTotal,
    weight: left.weight + right.weight,
  }),
  finalize: ({ weightedTotal, weight }) =>
    weight === 0 ? null : weightedTotal / weight,
};
```

Wired into a real column, that reducer is the only way to get a shares-weighted average onto a group row — the built-in `"avg"` preset can't do it, because it has no way to read a second field for weight. The example below puts both on the same grid so the two numbers visibly diverge, which is the actual proof the reducer works rather than a claim about it:

### Example: Custom aggregator: shares-weighted average price

A mergeable init/accumulate/merge/finalize reducer computes a VWAP per group, shown beside the built-in avg preset on the same data so the two numbers visibly diverge.

Source: https://pretable.ai/examples/weighted-average-aggregator.md

```tsx WeightedAverageGrid.tsx
"use client";

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

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

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

const VIEWPORT_HEIGHT = 340;

export function WeightedAverageGrid() {
  // Grouped by desk from the start, but the query stays controlled so the
  // grouping panel can still regroup or ungroup interactively.
  const [query, setQuery] = useState<
    NonNullable<
      ComponentProps<
        typeof PretableSurface<(typeof positions)[number]>
      >["query"]
    >
  >({
    filters: [],
    sort: [],
    rowGroups: [{ columnId: "desk", direction: "asc" }],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Grouped by desk. <strong>Avg price</strong> is the built-in{" "}
        <code>&quot;avg&quot;</code> preset; <strong>VWAP</strong> is the custom
        reducer above, weighted by <strong>Shares</strong> — compare the two
        numbers on a group row to see the weighting actually change the result,
        not just relabel it. Drag <strong>Sector</strong> onto the panel to add
        a second level.
      </p>
      <PretableSurface
        ariaLabel="Positions grouped by desk, comparing plain and weighted average price"
        columns={columns}
        getRowId={(row) => row.id}
        groupPanel={{ enabled: true }}
        onQueryChange={setQuery}
        query={query}
        rows={positions}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import { createColumnHelper } from "@pretable/core";
import type { PretableAggregator } from "@pretable/core";

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

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

const count = new Intl.NumberFormat("en-US");

// A mergeable reducer: `init` seeds an accumulator, `accumulate` folds in
// one leaf row's value, `merge` combines two partial accumulators (so the
// tree can aggregate bottom-up), and `finalize` turns the accumulator into
// the displayed output. `row` is the whole leaf row, so a weight can come
// from a different field than the one being averaged.
const weightedAveragePrice: PretableAggregator<
  Position,
  number,
  { weightedTotal: number; weight: number },
  number | null
> = {
  init: () => ({ weightedTotal: 0, weight: 0 }),
  accumulate: (accumulator, value, row) => ({
    weightedTotal: accumulator.weightedTotal + value * row.shares,
    weight: accumulator.weight + row.shares,
  }),
  merge: (left, right) => ({
    weightedTotal: left.weightedTotal + right.weightedTotal,
    weight: left.weight + right.weight,
  }),
  finalize: ({ weightedTotal, weight }) =>
    weight === 0 ? null : weightedTotal / weight,
};

const column = createColumnHelper<Position>();

export const columns = [
  column.accessor("desk", { type: "text", header: "Desk" }),
  column.accessor("sector", { type: "text", header: "Sector" }),
  column.accessor("symbol", { type: "text", header: "Symbol" }),
  column.accessor("shares", {
    type: "number",
    header: "Shares",
    aggregate: "sum",
    format: ({ value }) => count.format(value),
    formatAggregate: ({ value }) =>
      typeof value === "number" ? count.format(value) : "",
  }),
  column.accessor("price", {
    type: "number",
    header: "Price",
    format: ({ value }) => usd.format(value),
  }),
  // Aggregate-only columns: the leaf cell is blank, so a group row is the
  // only place either number appears — that's what makes the divergence
  // between them legible.
  column.accessor("avgPrice", (row) => row.price, {
    type: "number",
    header: "Avg price",
    aggregate: "avg",
    format: () => "—",
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "—",
  }),
  column.accessor("vwap", (row) => row.price, {
    type: "number",
    header: "VWAP (wtd by shares)",
    aggregate: weightedAveragePrice,
    format: () => "—",
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "—",
  }),
] as const;
```

```ts data.ts
export interface Position {
  id: string;
  desk: string;
  sector: string;
  symbol: string;
  shares: number;
  price: number;
}

/**
 * Each group mixes one very large position with several small ones — a
 * plain arithmetic mean and a shares-weighted mean diverge visibly, which is
 * the whole point: it's proof the custom aggregator did something a preset
 * couldn't, not just algebra on paper.
 */
export const positions: Position[] = [
  {
    id: "p1",
    desk: "Equities",
    sector: "Technology",
    symbol: "NVDA",
    shares: 9000,
    price: 118,
  },
  {
    id: "p2",
    desk: "Equities",
    sector: "Technology",
    symbol: "MSFT",
    shares: 300,
    price: 410,
  },
  {
    id: "p3",
    desk: "Equities",
    sector: "Technology",
    symbol: "AAPL",
    shares: 250,
    price: 227,
  },
  {
    id: "p4",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "LLY",
    shares: 6200,
    price: 780,
  },
  {
    id: "p5",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "UNH",
    shares: 180,
    price: 512,
  },
  {
    id: "p6",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "PFE",
    shares: 220,
    price: 28,
  },
  {
    id: "p7",
    desk: "Credit",
    sector: "Financials",
    symbol: "JPM",
    shares: 7100,
    price: 205,
  },
  {
    id: "p8",
    desk: "Credit",
    sector: "Financials",
    symbol: "GS",
    shares: 140,
    price: 460,
  },
  {
    id: "p9",
    desk: "Credit",
    sector: "Financials",
    symbol: "MS",
    shares: 190,
    price: 98,
  },
  {
    id: "p10",
    desk: "Credit",
    sector: "Energy",
    symbol: "XOM",
    shares: 5400,
    price: 112,
  },
  {
    id: "p11",
    desk: "Credit",
    sector: "Energy",
    symbol: "CVX",
    shares: 210,
    price: 155,
  },
];
```


Aggregates fold descendant leaf rows, never child aggregate values. By default they use post-filter descendants. Set `aggregateFilteredRows` to `true` to aggregate the full group population while displaying only matching descendants; `childCount` remains post-filter.

## Expansion

**Groups start expanded.** Grouping is an interactive act as much as a configuration one — a user drags a column into the group panel while reading their rows — and collapsing on drop would hide the data they were just looking at. Pass `initialExpansion` to choose a different policy; `{ kind: "through-depth", depth: 0 }` opens only the top level, which is the one to reach for when the grouped population is too large to draw at once.

Below, the grid is grouped two levels deep (desk, then sector) but constructed with `{ kind: "through-depth", depth: 0 }`, so only the desk level starts open — click a twisty, or arrow onto a group row and press Arrow Right, to expand a sector and see the difference from the always-expanded grids above:

### Example: Expansion policy: through-depth

Two group levels seeded from the start, with sector groups collapsed by initialExpansion — click a twisty or use the arrow keys to expand one and reveal its positions.

Source: https://pretable.ai/examples/group-expansion-control.md

```tsx GroupExpansionGrid.tsx
"use client";

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

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

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

const VIEWPORT_HEIGHT = 340;

export function GroupExpansionGrid() {
  // Two group levels from the start (desk, then sector), controlled so the
  // panel can still reorder or remove a level.
  const [query, setQuery] = useState<
    NonNullable<
      ComponentProps<
        typeof PretableSurface<(typeof positions)[number]>
      >["query"]
    >
  >({
    filters: [],
    sort: [],
    rowGroups: [
      { columnId: "desk", direction: "asc" },
      { columnId: "sector", direction: "asc" },
    ],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        The <code>through-depth</code> expansion policy (depth 0) opens only the
        top level here — sector groups start collapsed. Click a ▾ twisty, or
        focus a group row and press <kbd>Arrow Right</kbd>, to expand a sector
        and see its positions.
      </p>
      <PretableSurface
        ariaLabel="Positions grouped by desk and sector, sector groups collapsed"
        columns={columns}
        getRowId={(row) => row.id}
        groupPanel={{ enabled: true }}
        initialExpansion={{ kind: "through-depth", depth: 0 }}
        onQueryChange={setQuery}
        query={query}
        rows={positions}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

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

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

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

const count = new Intl.NumberFormat("en-US");

export const columns: PretableColumn<Position>[] = [
  { id: "desk", header: "Desk" },
  { id: "sector", header: "Sector" },
  { id: "symbol", header: "Symbol" },
  {
    id: "shares",
    header: "Shares",
    type: "number",
    aggregate: "sum",
    format: ({ value }) => count.format(value as number),
    formatAggregate: ({ value }) =>
      typeof value === "number" ? count.format(value) : "",
  },
  {
    id: "marketValue",
    header: "Market value",
    type: "number",
    aggregate: "sum",
    format: ({ value }) => usd.format(value as number),
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "",
  },
];
```

```ts data.ts
export interface Position {
  id: string;
  desk: string;
  sector: string;
  symbol: string;
  shares: number;
  marketValue: number;
}

export const positions: Position[] = [
  {
    id: "p1",
    desk: "Equities",
    sector: "Technology",
    symbol: "NVDA",
    shares: 4200,
    marketValue: 512_400,
  },
  {
    id: "p2",
    desk: "Equities",
    sector: "Technology",
    symbol: "MSFT",
    shares: 1800,
    marketValue: 748_900,
  },
  {
    id: "p3",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "LLY",
    shares: 620,
    marketValue: 486_100,
  },
  {
    id: "p4",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "UNH",
    shares: 950,
    marketValue: 501_300,
  },
  {
    id: "p5",
    desk: "Equities",
    sector: "Energy",
    symbol: "XOM",
    shares: 3100,
    marketValue: 364_800,
  },
  {
    id: "p6",
    desk: "Credit",
    sector: "Financials",
    symbol: "JPM",
    shares: 2400,
    marketValue: 623_500,
  },
  {
    id: "p7",
    desk: "Credit",
    sector: "Financials",
    symbol: "GS",
    shares: 780,
    marketValue: 419_700,
  },
  {
    id: "p8",
    desk: "Credit",
    sector: "Energy",
    symbol: "CVX",
    shares: 1500,
    marketValue: 238_200,
  },
  {
    id: "p9",
    desk: "Macro",
    sector: "Financials",
    symbol: "TLT",
    shares: 5600,
    marketValue: 497_800,
  },
  {
    id: "p10",
    desk: "Macro",
    sector: "Energy",
    symbol: "USO",
    shares: 8800,
    marketValue: 611_600,
  },
  {
    id: "p11",
    desk: "Macro",
    sector: "Technology",
    symbol: "SMH",
    shares: 1250,
    marketValue: 329_400,
  },
];
```


Choose the initial policy when the rows-mode model is constructed:

```tsx
<PretableSurface
  ariaLabel="Orders"
  columns={columns}
  getRowId={(row) => row.id}
  groupPanel={{ enabled: true }}
  initialExpansion={{ kind: "through-depth", depth: 0 }}
  rows={rows}
  viewportHeight={480}
/>
```

The policies are `{ kind: "collapsed" }`, `{ kind: "expanded" }`, and `{ kind: "through-depth", depth }`. For an explicit model, use the same construction option, then call `rowModel.setGroupExpanded(groupId, expanded)`, `setExpansionDefault`, `expandAll`, or `collapseAll`.

Group IDs are stable, collision-safe, and derived from the complete typed group path. They survive leaf updates and filtering. Reordering grouping levels changes those paths, so obsolete expansion overrides are discarded.

## Keyboard

With focus on a group row, Arrow Left collapses an expanded group and Arrow Right expands a collapsed group. Enter and Space toggle expansion. In the grouping panel, Arrow Left and Arrow Right reorder the focused chip, while Delete or Backspace removes it. These commands preserve the same indexed focus and selection references used by ungrouped rows.

## Group-column presentation

```tsx
<PretableSurface
  ariaLabel="Orders"
  columns={columns}
  getRowId={(row) => row.id}
  groupColumn={{ header: "Group", widthPx: 260, pinned: "left" }}
  groupPanel={{ enabled: true }}
  hideGroupedColumns={false}
  rows={rows}
  viewportHeight={480}
/>
```

`hideGroupedColumns` defaults to `true`; set it to `false` when original value columns should remain beside the tree column. The derived group column cannot itself become another grouping level.

## Sorting under grouping

Sort entries order leaf rows within each group. A grouping entry's `direction` orders sibling group keys at that level. Because grouping, filters, and sort are one query, a controlled surface publishes them together through `onQueryChange` and an explicit model replaces them atomically with `rowModel.setQuery(...)`.

Pretable does not currently expose tree-data input, pivoting, total rows, or per-chip aggregate selection. Grouping always derives a hierarchy from the active query and declared column aggregators.

## See also

- [Filtering](/docs/grid/filtering) — filter operators and aggregate population.
- [Sorting](/docs/grid/sorting) — leaf ordering and group-level direction.
- [Number formatting](/docs/grid/number-formatting) — locale-aware leaf and aggregate values.
- [`<PretableSurface>`](/docs/grid/pretable-surface) — complete surface props.
- [Grid API reference](/docs/grid/api-reference) — query, expansion, and aggregate types.
