shadimage
data-table-filters
blockdata-table-actionssource: ok · hit

Data Table Row Actions

Row, bulk, and filter-scoped actions declared next to their Drizzle handlers and rendered from JSON: the list endpoint advertises what can be done to each row, the UI renders buttons, one POST applies it in a transaction.

npx shadcn add "https://data-table.openstatus.dev/r/data-table-actions.json"
free

Where it goes

data-table

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@tanstack/react-query@^5.101.4drizzle-orm@^0.45.2zod@^4.3.6lucide-react@^0.469.0sonner@^1.7.2

Registry dependencies

https://data-table.openstatus.dev/r/data-table.jsonhttps://data-table.openstatus.dev/r/data-table-drizzle.jsonhttps://data-table.openstatus.dev/r/data-table-query.jsonbuttondropdown-menualert-dialog

Files9 · source cached 10/15/2026, 8:40:11 AM

src/lib/actions/index.ts
export {
  defineActions,
  type ActionDefinitionBase,
  type DefinedActions,
  type DefineActionsOptions,
} from "./define-actions";
export {
  ROW_ACTIONS_KEY,
  type ActionDescriptor,
  type ActionError,
  type ActionErrorCode,
  type ActionRequest,
  type ActionResponse,
  type ActionScope,
  type ActionVariant,
  type WithRowActions,
} from "./types";
export {
  isSafeActionHref,
  sanitizeActionDescriptors,
  validateActionDescriptor,
  type ActionRejection,
  type ActionValidationOptions,
} from "./validate";
src/lib/actions/define-actions.ts
import type { Filters } from "@/lib/filters";
import {
  ROW_ACTIONS_KEY,
  type ActionDescriptor,
  type ActionScope,
  type ActionVariant,
  type WithRowActions,
} from "./types";

/**
 * The declarative half of an action — everything except how it executes.
 *
 * `createActionHandler` (Drizzle) adds the `handler`; this module never looks
 * at it, which is what keeps `annotate` and `descriptors` importable anywhere.
 */
export type ActionDefinitionBase<
  TRow = Record<string, unknown>,
  TValues = Record<string, unknown>,
> = {
  label: string;
  /** Defaults to `["row", "bulk"]`. */
  scope?: ActionScope[];
  variant?: ActionVariant;
  /**
   * Confirmation copy. `{count}` is replaced with the affected row count and
   * `{one|other}` picks a form by it: `"Delete {count} {log|logs}?"`.
   */
  confirm?: string;
  /**
   * Availability, as filter values — `{ status: ["dead"] }` — in exactly the
   * shape the list endpoint reads from its search params.
   *
   * One declaration, two engines: `filters.matches` evaluates it per row to
   * compute `_actions`, and the SQL engine compiles it into the handler's WHERE
   * guard. Keys must be filterable columns; anything else throws at
   * construction rather than silently matching every row.
   *
   * Typed by the `Filters` the actions are defined against: from a table
   * schema definition, `TValues` is `FilterValues<typeof definition>`, so a
   * key that is not a filterable column or a value the column cannot filter
   * on (`{ level: ["fatal"] }` against `col.enum(LEVELS)`) is a compile
   * error before it is a runtime one.
   */
  when?: Partial<TValues>;
  /**
   * JS-only escape hatch for availability the filter semantics cannot express
   * (a computed field, a non-filterable column). It only shapes `_actions`;
   * there is no SQL counterpart, so the handler must guard itself.
   */
  available?: (row: TRow) => boolean;
};

export type DefineActionsOptions = {
  /** `href` is `${basePath}/${id}`. */
  basePath: string;
  /** Published on `bulk` descriptors as `maxIds`. */
  maxIds?: number;
};

export type DefinedActions<TDef extends ActionDefinitionBase<never>> = {
  /** The declarations, with defaults applied. Keyed by id. */
  definitions: ReadonlyMap<string, TDef & { scope: ActionScope[] }>;
  /** Public metadata for the wire. Same order as declared. */
  descriptors: ActionDescriptor[];
  /** Stamp every row with the ids it currently qualifies for. */
  annotate<TRow>(rows: readonly TRow[]): WithRowActions<TRow>[];
  /** The ids one row qualifies for. */
  actionsFor(row: unknown): string[];
};

const DEFAULT_SCOPE: ActionScope[] = ["row", "bulk"];

/**
 * An id is a URL segment and an audit-log key. Keep it boring. The leading
 * letter is not taste: `Object.entries` hoists integer-like keys (`"1"`,
 * `"42"`) to the front in numeric order, which would silently reorder
 * `descriptors` and the menus built from them.
 */
const ID_PATTERN = /^[a-z][a-z0-9_-]*$/;

/**
 * The pick-list. Adding a field to `ActionDescriptor` fails to compile here
 * until it is either projected or deliberately excluded — the failure mode
 * this guards against is `{ ...definition }` leaking a handler, a secret, or a
 * `when` clause the server never meant to publish.
 */
const PUBLIC_KEYS = {
  id: true,
  label: true,
  scope: true,
  variant: true,
  confirm: true,
  href: true,
  maxIds: true,
} as const satisfies Record<keyof ActionDescriptor, true>;

type _PublicKeysExhaustive = [
  Exclude<keyof ActionDescriptor, keyof typeof PUBLIC_KEYS>,
] extends [never]
  ? true
  : never;
const _publicKeysExhaustive: _PublicKeysExhaustive = true;
void _publicKeysExhaustive;

function toDescriptor(
  id: string,
  definition: ActionDefinitionBase<never> & { scope: ActionScope[] },
  options: DefineActionsOptions,
): ActionDescriptor {
  const descriptor: ActionDescriptor = {
    id,
    label: definition.label,
    scope: [...definition.scope],
    href: `${options.basePath}/${id}`,
  };
  // Optional keys are only present when set, so the JSON stays canonical.
  if (definition.variant !== undefined) descriptor.variant = definition.variant;
  if (definition.confirm !== undefined) descriptor.confirm = definition.confirm;
  // Only a bulk request can carry more than one id, so only there does the
  // limit mean anything to the client.
  if (options.maxIds !== undefined && definition.scope.includes("bulk")) {
    descriptor.maxIds = options.maxIds;
  }
  return descriptor;
}

/**
 * Validate a `when` clause against the declared filter semantics.
 *
 * `filters.plan` silently drops unknown keys and inactive values, which is
 * right for search params and wrong for an availability guard: a typo would
 * turn "only dead rows" into "every row". So every key must be a spec, and
 * every value must plan to an op.
 */
function assertWhen(
  id: string,
  when: Record<string, unknown>,
  filters: Filters,
): void {
  for (const key of Object.keys(when)) {
    if (!filters.spec(key)) {
      throw new Error(
        `[defineActions] Action "${id}": when.${JSON.stringify(key)} is not a filterable column. ` +
          `Filterable: ${filters.specs.map((spec) => spec.key).join(", ") || "(none)"}`,
      );
    }
    const ops = filters.plan({ [key]: when[key] });
    if (ops.length === 0) {
      throw new Error(
        `[defineActions] Action "${id}": when.${JSON.stringify(key)} is not an active filter value ` +
          `(got ${JSON.stringify(when[key])}). An empty guard would match every row.`,
      );
    }
  }
}

/**
 * Turn action declarations into the two things the rest of the system needs:
 * public descriptors for the wire, and a per-row availability stamp.
 *
 * Generic over the definition type so the Drizzle handler can carry its
 * `handler` through without this module depending on Drizzle, and over the
 * filter values so `when` is checked against the columns `filters` declares.
 */
export function defineActions<
  TDef extends ActionDefinitionBase<never, TValues>,
  TValues extends Record<string, unknown> = Record<string, unknown>,
>(
  filters: Filters<TValues>,
  actions: Record<string, TDef>,
  options: DefineActionsOptions,
): DefinedActions<TDef> {
  const basePath = options.basePath.replace(/\/+$/, "");
  if (basePath.length === 0) {
    throw new Error(`[defineActions] basePath must be a non-empty path`);
  }
  if (
    options.maxIds !== undefined &&
    (!Number.isInteger(options.maxIds) || options.maxIds < 1)
  ) {
    throw new Error(
      `[defineActions] maxIds must be a positive integer, got ${String(options.maxIds)}`,
    );
  }
  const resolved: DefineActionsOptions = { ...options, basePath };

  const definitions = new Map<string, TDef & { scope: ActionScope[] }>();
  const descriptors: ActionDescriptor[] = [];

  for (const [id, definition] of Object.entries(actions)) {
    if (!ID_PATTERN.test(id)) {
      throw new Error(
        `[defineActions] Action id ${JSON.stringify(id)} must match ${ID_PATTERN} — it becomes a URL segment.`,
      );
    }
    if (!definition.label) {
      throw new Error(`[defineActions] Action "${id}" needs a label`);
    }

    const scope = Array.from(new Set(definition.scope ?? DEFAULT_SCOPE));
    if (scope.length === 0) {
      throw new Error(`[defineActions] Action "${id}" declares an empty scope`);
    }

    if (definition.when) assertWhen(id, definition.when, filters);

    const withDefaults = { ...definition, scope };
    definitions.set(id, withDefaults);
    descriptors.push(toDescriptor(id, withDefaults, resolved));
  }

  const actionsFor = (row: unknown): string[] => {
    const ids: string[] = [];
    for (const [id, definition] of definitions) {
      if (definition.when && !filters.matches(definition.when, row)) continue;
      if (definition.available && !definition.available(row as never)) continue;
      ids.push(id);
    }
    return ids;
  };

  return {
    definitions,
    descriptors,
    actionsFor,
    annotate: (rows) =>
      rows.map((row) => ({ ...row, [ROW_ACTIONS_KEY]: actionsFor(row) })),
  };
}
src/lib/drizzle/actions.ts
import {
  defineActions,
  type ActionDefinitionBase,
  type ActionDescriptor,
  type ActionErrorCode,
  type ActionRequest,
  type ActionResponse,
  type WithRowActions,
} from "@/lib/actions";
import type { Filters } from "@/lib/filters";
import { and, count, inArray, sql, type SQL } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { buildWhereConditions } from "./filters";
import type { ColumnMapping, DrizzleDB } from "./types";

/**
 * What a handler receives.
 *
 * `where` is the authority: the requested set (ids, or a filter) intersected
 * with the action's `when` guard. A handler that uses it cannot touch a row
 * the action does not apply to, no matter what the client sent.
 */
export type ActionContext = {
  where: SQL;
  /** Present for `scope: "ids"`. */
  ids?: string[];
  /**
   * Present for `scope: "filter"`. Pruned to keys the filter semantics know;
   * the values are exactly what the client sent — never `filters.coerce`d, so
   * a slider range is not clamped to its declared bounds. See `execute`.
   */
  filter?: Record<string, unknown>;
  /** Whoever the route authenticated. Never read from the request body. */
  actor?: string;
  /** Client-generated; at-least-once, see `ActionRequest`. */
  cmdId: string;
};

export type DrizzleActionDefinition<
  TRow = Record<string, unknown>,
  TValues = Record<string, unknown>,
> = ActionDefinitionBase<TRow, TValues> & {
  /**
   * Runs inside one transaction and returns how many rows it applied to.
   *
   * Actions enqueue; they don't execute. A replay flips `status` to
   * `pending` and lets the worker do the work — that is why one short
   * transaction is enough and why nothing here does I/O.
   *
   * ```ts
   * handler: async (ctx, tx) => {
   *   const rows = await tx
   *     .update(outbox)
   *     .set({ status: "pending", attempt: 0 })
   *     .where(ctx.where)
   *     .returning({ id: outbox.id });
   *   return rows.length;
   * }
   * ```
   */
  handler: (ctx: ActionContext, tx: DrizzleDB) => Promise<number>;
};

export type ActionAuditEvent = {
  action: string;
  actor?: string;
  cmdId: string;
  scope: ActionRequest["scope"];
  ids?: string[];
  filter?: Record<string, unknown>;
  applied: number;
  at: Date;
};

export type ActionHandlerConfig<
  TRow = Record<string, unknown>,
  TValues = Record<string, unknown>,
> = {
  db: DrizzleDB;
  table: PgTable;
  /**
   * The same `defineFilters(...)` the list handler uses. Built from the table
   * schema definition, it also types every action's `when` guard.
   */
  filters: Filters<TValues>;
  columnMapping: ColumnMapping;
  /** The schema key that identifies a row. Must be in `columnMapping`. */
  idColumn: string;
  actions: Record<string, DrizzleActionDefinition<TRow, TValues>>;
  /** `href` is `${basePath}/${id}` — the route that calls `execute`. */
  basePath: string;
  /**
   * Upper bound on `ids.length`; larger requests are `invalid_request`. Also
   * published on `bulk` descriptors so the client can stop short of it.
   */
  maxIds?: number;
  /**
   * The shape of one id, when the column is stricter than "non-empty string"
   * — `z.uuid()` for a `uuid` column. Without it a malformed id reaches
   * Postgres, whose cast error surfaces as a 500 rather than a 400.
   *
   * Parsed asynchronously, so an async refinement is allowed here.
   */
  idSchema?: z.ZodType<string>;
  /**
   * Called once per applied command, after the transaction committed. If it
   * throws, the mutation has already happened — the error propagates so the
   * route can decide, but nothing is rolled back.
   */
  audit?: (event: ActionAuditEvent) => void | Promise<void>;
};

const STATUS_BY_CODE: Record<ActionErrorCode, number> = {
  unknown_action: 404,
  scope_not_allowed: 400,
  invalid_request: 400,
  count_mismatch: 409,
  forbidden: 403,
  failed: 500,
};

/** A typed failure the route maps to an HTTP response. */
export class ActionHandlerError extends Error {
  readonly code: ActionErrorCode;
  readonly status: number;
  /** Present on `count_mismatch`. */
  readonly actual?: number;

  constructor(code: ActionErrorCode, message: string, actual?: number) {
    super(message);
    this.name = "ActionHandlerError";
    this.code = code;
    this.status = STATUS_BY_CODE[code];
    if (actual !== undefined) this.actual = actual;
  }

  /** The JSON body for the wire. */
  toJSON(): { error: ActionErrorCode; actual?: number } {
    return this.actual === undefined
      ? { error: this.code }
      : { error: this.code, actual: this.actual };
  }
}

function createRequestSchema(idSchema: z.ZodType<string>) {
  return z.discriminatedUnion("scope", [
    z.object({
      scope: z.literal("ids"),
      ids: z.array(idSchema).min(1),
      cmd_id: z.string().min(1),
    }),
    z.object({
      scope: z.literal("filter"),
      filter: z.record(z.string(), z.unknown()),
      expected_count: z.number().int().nonnegative().optional(),
      cmd_id: z.string().min(1),
    }),
  ]);
}

/** Postgres SQLSTATE for "could not serialize access". */
const SERIALIZATION_FAILURE = "40001";
/** How often a serialization failure is retried before it propagates. */
const MAX_ATTEMPTS = 3;

/**
 * Drivers report SQLSTATE as `code`, and Drizzle wraps the driver error in a
 * `DrizzleQueryError` whose `cause` is the original — so walk the chain.
 */
export function isSerializationFailure(error: unknown): boolean {
  let current: unknown = error;
  for (
    let depth = 0;
    depth < 5 && typeof current === "object" && current;
    depth++
  ) {
    if ((current as { code?: unknown }).code === SERIALIZATION_FAILURE) {
      return true;
    }
    current = (current as { cause?: unknown }).cause;
  }
  return false;
}

export type ActionHandler<TRow = Record<string, unknown>> = {
  /** Public metadata for `meta.actions`. */
  descriptors: ActionDescriptor[];
  /** Stamp fetched rows with `_actions`. */
  annotate<T extends TRow>(rows: readonly T[]): WithRowActions<T>[];
  /**
   * Run one action. Throws `ActionHandlerError` for every contract violation;
   * anything else that escapes is the handler's own failure, already rolled
   * back.
   */
  execute(
    actionId: string,
    body: unknown,
    options?: { actor?: string },
  ): Promise<ActionResponse>;
};

/**
 * The write-side sibling of `createDrizzleHandler`.
 *
 * Shares its `filters` and `columnMapping`, so an action's `when` guard and a
 * filter-scoped request compile through the very same `buildWhereConditions`
 * the list endpoint uses — the set the user saw is the set the action hits.
 */
export function createActionHandler<
  TRow = Record<string, unknown>,
  TValues extends Record<string, unknown> = Record<string, unknown>,
>(config: ActionHandlerConfig<TRow, TValues>): ActionHandler<TRow> {
  const {
    db,
    table,
    filters,
    columnMapping,
    idColumn,
    basePath,
    maxIds = 1000,
    idSchema = z.string().min(1),
    audit,
  } = config;
  const requestSchema = createRequestSchema(idSchema);

  const idCol = columnMapping[idColumn];
  if (!idCol) {
    throw new Error(
      `[createActionHandler] idColumn "${idColumn}" not found in columnMapping`,
    );
  }

  // Mirror `createDrizzleHandler`: `buildWhereConditions` skips a key that is
  // missing from the mapping. For a `when` guard that would mean "every row";
  // for a filter-scoped request, "more rows than the user saw". Fail here.
  const unmapped = filters.specs
    .map((spec) => spec.key)
    .filter((key) => !columnMapping[key]);
  if (unmapped.length > 0) {
    throw new Error(
      `[createActionHandler] These filterable columns are missing from columnMapping:\n` +
        unmapped.map((key) => `  - ${key}`).join("\n"),
    );
  }

  const defined = defineActions<
    DrizzleActionDefinition<TRow, TValues>,
    TValues
  >(filters, config.actions, { basePath, maxIds });

  // `when` guards never change after construction, so compile them once.
  const guards = new Map<string, SQL[]>();
  for (const [id, definition] of defined.definitions) {
    guards.set(
      id,
      definition.when
        ? buildWhereConditions(filters, definition.when, columnMapping)
        : [],
    );
  }

  async function execute(
    actionId: string,
    body: unknown,
    options: { actor?: string } = {},
  ): Promise<ActionResponse> {
    const definition = defined.definitions.get(actionId);
    if (!definition) {
      throw new ActionHandlerError("unknown_action", `No action "${actionId}"`);
    }

    // Async so a caller's `idSchema` may carry an async refinement; a sync
    // schema parses just the same through it.
    const parsed = await requestSchema.safeParseAsync(body);
    if (!parsed.success) {
      throw new ActionHandlerError(
        "invalid_request",
        `Invalid request body: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`,
      );
    }
    const request = parsed.data;

    const guard = guards.get(actionId) ?? [];
    let where: SQL;
    /** The set the client counted — the filter alone, before the guard. */
    let shown: SQL | undefined;
    let ids: string[] | undefined;
    let filter: Record<string, unknown> | undefined;

    if (request.scope === "ids") {
      const allowed =
        definition.scope.includes("row") || definition.scope.includes("bulk");
      if (!allowed) {
        throw new ActionHandlerError(
          "scope_not_allowed",
          `Action "${actionId}" does not accept ids (scope: ${definition.scope.join(", ")})`,
        );
      }
      if (!definition.scope.includes("bulk") && request.ids.length > 1) {
        throw new ActionHandlerError(
          "scope_not_allowed",
          `Action "${actionId}" accepts one id at a time (scope: ${definition.scope.join(", ")})`,
        );
      }
      if (request.ids.length > maxIds) {
        throw new ActionHandlerError(
          "invalid_request",
          `Too many ids (${request.ids.length} > ${maxIds}); use scope "filter"`,
        );
      }
      ids = Array.from(new Set(request.ids));
      where = and(inArray(idCol, ids), ...guard)!;
    } else {
      if (!definition.scope.includes("filter")) {
        throw new ActionHandlerError(
          "scope_not_allowed",
          `Action "${actionId}" does not accept a filter (scope: ${definition.scope.join(", ")})`,
        );
      }
      // The same interpretation as the list endpoint: `filters.plan` (inside
      // `buildWhereConditions`) drops unknown keys and inactive values and
      // never clamps. `filters.coerce` would clamp a slider to its declared
      // bounds — and a range the user dragged past those bounds must hit the
      // rows they saw, not a narrower set. Unknown keys are dropped from the
      // recorded filter as well.
      filter = Object.fromEntries(
        Object.entries(request.filter).filter(
          ([key]) => filters.spec(key) !== undefined,
        ),
      );
      const visible = buildWhereConditions(filters, filter, columnMapping);
      // An empty filter is "every row". Legal — `expected_count` is the
      // client's safety net — but the handler still gets a real SQL.
      shown = and(...visible) ?? sql`true`;
      where = and(...visible, ...guard) ?? sql`true`;
    }

    const ctx: ActionContext = {
      where,
      cmdId: request.cmd_id,
      ...(ids ? { ids } : {}),
      ...(filter ? { filter } : {}),
      ...(options.actor !== undefined ? { actor: options.actor } : {}),
    };
    const expectedCount =
      request.scope === "filter" ? request.expected_count : undefined;

    const run = () =>
      db.transaction(
        async (tx) => {
          if (expectedCount !== undefined && shown !== undefined) {
            // Drift is measured on the set the client was shown, not on the
            // guarded set: a guard makes `applied` smaller by design, and that
            // is not "the set changed".
            const [row] = await tx
              .select({ n: count() })
              .from(table)
              .where(shown);
            const actual = Number(row?.n ?? 0);
            if (actual !== expectedCount) {
              throw new ActionHandlerError(
                "count_mismatch",
                `Expected ${expectedCount} rows, found ${actual}`,
                actual,
              );
            }
          }
          const result = await definition.handler(ctx, tx);
          if (!Number.isInteger(result) || result < 0) {
            throw new Error(
              `[createActionHandler] Action "${actionId}" handler must return the applied row count, got ${String(result)}`,
            );
          }
          return result;
        },
        // The count is only a promise if the mutation sees the same rows.
        // Under READ COMMITTED each statement takes its own snapshot, so a row
        // committed in between is counted by neither and updated anyway.
        // REPEATABLE READ pins one snapshot for the whole transaction: a
        // concurrent insert is invisible to both statements, and a concurrent
        // change to a counted row fails with 40001 instead of being applied
        // over — which is retried below against a fresh snapshot, where the
        // recount catches it as `count_mismatch`.
        expectedCount !== undefined
          ? { isolationLevel: "repeatable read" }
          : undefined,
      );

    let applied: number;
    for (let attempt = 1; ; attempt++) {
      try {
        applied = await run();
        break;
      } catch (error) {
        if (attempt >= MAX_ATTEMPTS || !isSerializationFailure(error)) {
          throw error;
        }
      }
    }

    if (audit) {
      await audit({
        action: actionId,
        cmdId: request.cmd_id,
        scope: request.scope,
        applied,
        at: new Date(),
        ...(ids ? { ids } : {}),
        ...(filter ? { filter } : {}),
        ...(options.actor !== undefined ? { actor: options.actor } : {}),
      });
    }

    return { applied };
  }

  return {
    descriptors: defined.descriptors,
    annotate: (rows) => defined.annotate(rows),
    execute,
  };
}
src/components/data-table/data-table-actions/index.ts
export { DataTableActionsBar } from "./bar";
export { createActionsColumn, DataTableActionsCell } from "./column";
export {
  DataTableActionsConfirmDialog,
  describeCommand,
  type ConfirmingCommandView,
} from "./confirm-dialog";
export {
  DataTableActionsProvider,
  useDataTableActions,
  type ActionRequestInput,
  type AppliedEvent,
  type DataTableActionsContextValue,
  type DataTableActionsProviderProps,
  type TriggerMeta,
} from "./provider";
export {
  ActionRequestError,
  actionsForScope,
  interpolate,
  newCommandId,
  partitionRows,
  postAction,
  rowActionsOf,
  rowScopedActions,
} from "./utils";
src/components/data-table/data-table-actions/provider.tsx
"use client";

import type {
  ActionDescriptor,
  ActionRequest,
  ActionResponse,
} from "@/lib/actions/types";
import { sanitizeActionDescriptors } from "@/lib/actions/validate";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { toast } from "sonner";
import {
  DataTableActionsConfirmDialog,
  type ConfirmingCommandView,
} from "./confirm-dialog";
import {
  ActionRequestError,
  newCommandId,
  postAction,
  rowActionsOf,
} from "./utils";

type DistributiveOmit<T, K extends keyof T> = T extends unknown
  ? Omit<T, K>
  : never;

/** A request minus the `cmd_id` the provider stamps on at send time. */
export type ActionRequestInput = DistributiveOmit<ActionRequest, "cmd_id">;

export type TriggerMeta = {
  /** How many rows the user was shown. */
  count: number;
  /** Selected rows the action does not apply to. */
  skipped?: number;
  /** Runs after a 2xx — e.g. clear the selection the action consumed. */
  onApplied?: () => void;
};

export type AppliedEvent = {
  action: ActionDescriptor;
  request: ActionRequest;
  response: ActionResponse;
};

export type DataTableActionsContextValue<TData = unknown> = {
  actions: ActionDescriptor[];
  getRowId: (row: TData) => string;
  getRowActions: (row: TData) => string[];
  /**
   * A human name for the row, for the actions trigger's accessible label.
   * `undefined` when the host did not supply one — the trigger then falls
   * back to static text rather than reading out the internal row id.
   */
  getRowLabel?: (row: TData) => string;
  /**
   * Start an action. Opens the confirmation if the descriptor asks for one,
   * otherwise sends immediately. A `count_mismatch` reopens the dialog with
   * the server's number and offers to apply anyway.
   */
  trigger: (
    action: ActionDescriptor,
    request: ActionRequestInput,
    meta: TriggerMeta,
  ) => void;
  isPending: boolean;
};

const DataTableActionsContext =
  React.createContext<DataTableActionsContextValue<never> | null>(null);

export function useDataTableActions<TData = unknown>() {
  const context = React.useContext(DataTableActionsContext);
  if (!context) {
    throw new Error(
      "useDataTableActions must be used within a DataTableActionsProvider",
    );
  }
  return context as unknown as DataTableActionsContextValue<TData>;
}

/** What the dialog shows, plus what to send once the user says yes. */
type ConfirmingCommand = ConfirmingCommandView & {
  request: ActionRequestInput;
  onApplied?: () => void;
};

export type DataTableActionsProviderProps<TData> = {
  /** From `meta.actions`. `undefined` while loading renders no buttons. */
  actions?: ActionDescriptor[];
  getRowId: (row: TData) => string;
  /** Defaults to the server's `_actions` stamp. */
  getRowActions?: (row: TData) => string[];
  /**
   * Names the row for screen readers ("Actions for <label>"). Pass something
   * the user can recognize — a title, an email — not the id the wire uses.
   * Omitted, every trigger reads "Row actions".
   */
  getRowLabel?: (row: TData) => string;
  /**
   * Origins the actions endpoint may live on, beyond the page's own.
   *
   * Descriptors are server-authored and choose the URL this provider POSTs row
   * ids to. Relative hrefs need nothing here; an absolute one is only sent if
   * its origin is the page's own or listed here. Anything else is dropped
   * before it can be rendered — see `sanitizeActionDescriptors`.
   */
  allowedActionOrigins?: readonly string[];
  /**
   * The first element of the table's query key. Every page under it is
   * invalidated after a successful action, so rows that no longer match
   * leave the view.
   */
  queryKeyPrefix?: string;
  onApplied?: (event: AppliedEvent) => void;
  /** Injected in tests. */
  fetcher?: typeof fetch;
  children: React.ReactNode;
};

export function DataTableActionsProvider<TData>({
  actions,
  getRowId,
  getRowActions,
  getRowLabel,
  allowedActionOrigins,
  queryKeyPrefix,
  onApplied,
  fetcher,
  children,
}: DataTableActionsProviderProps<TData>) {
  const queryClient = useQueryClient();

  // The descriptors are validated once, here, rather than at click time: a
  // button whose `href` we would refuse to POST to should never be drawn.
  const safeActions = React.useMemo(() => {
    const { actions: kept, rejected } = sanitizeActionDescriptors(actions, {
      allowedOrigins: allowedActionOrigins,
    });
    if (rejected.length > 0) {
      console.warn(
        "[data-table-actions] dropped unsafe or malformed descriptors:",
        rejected.map(({ id, reason }) => `${id} (${reason})`).join(", "),
      );
    }
    return kept;
  }, [actions, allowedActionOrigins]);
  // Awaiting the user's answer; distinct from `isPending` (request on the wire).
  const [confirming, setConfirming] = React.useState<ConfirmingCommand | null>(
    null,
  );
  // The `cmd_id` the dialog last submitted. Only that request may close the
  // dialog: an unconfirmed action settling must not tear down a confirmation
  // the user opened while it was still in flight.
  const submittedFromDialog = React.useRef<string | null>(null);

  const mutation = useMutation({
    mutationFn: (variables: {
      action: ActionDescriptor;
      request: ActionRequest;
      meta: TriggerMeta;
    }) => postAction(variables.action.href, variables.request, fetcher),
    onSuccess: (response, { action, request, meta }) => {
      if (request.scope === "filter") {
        // The client counted the filter-only set; the server applied within
        // the action's guard. A smaller number is the guard at work, not a
        // partial failure.
        toast.success(
          `${action.label}: applied to ${response.applied} of ${meta.count} matching`,
        );
      } else if (response.applied < request.ids.length) {
        // Only eligible ids were sent, so a shortfall means rows changed
        // between fetch and click.
        toast.warning(
          `${action.label}: applied to ${response.applied} of ${request.ids.length}`,
        );
      } else {
        toast.success(`${action.label}: applied to ${response.applied}`);
      }
      if (queryKeyPrefix) {
        void queryClient.invalidateQueries({ queryKey: [queryKeyPrefix] });
      }
      // The confirmation stays open, with its buttons disabled, until the
      // request it submitted settles — so the user sees the outcome land. Any
      // other action settling leaves it alone.
      if (submittedFromDialog.current === request.cmd_id) {
        submittedFromDialog.current = null;
        setConfirming(null);
      }
      meta.onApplied?.();
      onApplied?.({ action, request, response });
    },
    onError: (error, { action, request, meta }) => {
      if (
        error instanceof ActionRequestError &&
        error.code === "count_mismatch" &&
        request.scope === "filter"
      ) {
        const { cmd_id: _cmdId, ...input } = request;
        // A fresh dialog, nothing submitted from it yet.
        submittedFromDialog.current = null;
        setConfirming({
          action,
          request: input,
          count: meta.count,
          skipped: meta.skipped ?? 0,
          scope: "filter",
          actual: error.actual ?? 0,
          onApplied: meta.onApplied,
        });
        return;
      }
      if (submittedFromDialog.current === request.cmd_id) {
        submittedFromDialog.current = null;
        setConfirming(null);
      }
      toast.error(`${action.label} failed: ${error.message}`);
    },
  });

  // `mutate` is referentially stable; the result object is not, and depending
  // on it would rebuild `trigger` — and the context value — on every render.
  const { mutate, isPending } = mutation;

  const send = React.useCallback(
    (
      action: ActionDescriptor,
      request: ActionRequestInput,
      meta: TriggerMeta,
      options?: { fromDialog?: boolean },
    ) => {
      const cmdId = newCommandId();
      if (options?.fromDialog) submittedFromDialog.current = cmdId;
      mutate({
        action,
        request: { ...request, cmd_id: cmdId } as ActionRequest,
        meta,
      });
    },
    [mutate],
  );

  const trigger = React.useCallback<
    DataTableActionsContextValue<TData>["trigger"]
  >(
    (action, request, meta) => {
      if (action.confirm) {
        setConfirming({
          action,
          request,
          count: meta.count,
          skipped: meta.skipped ?? 0,
          scope: request.scope,
          onApplied: meta.onApplied,
        });
        return;
      }
      send(action, request, meta);
    },
    [send],
  );

  const confirm = React.useCallback(() => {
    if (!confirming) return;
    const { action, request, count, skipped, actual, onApplied } = confirming;
    if (actual !== undefined && request.scope === "filter") {
      // The user accepted the server's number: drop the optimistic check.
      const { expected_count: _expected, ...rest } = request;
      send(
        action,
        rest,
        { count: actual, skipped, onApplied },
        {
          fromDialog: true,
        },
      );
      return;
    }
    send(action, request, { count, skipped, onApplied }, { fromDialog: true });
  }, [confirming, send]);

  const cancel = React.useCallback(() => {
    submittedFromDialog.current = null;
    setConfirming(null);
  }, []);

  const value = React.useMemo<DataTableActionsContextValue<TData>>(
    () => ({
      actions: safeActions,
      getRowId,
      getRowActions: getRowActions ?? rowActionsOf,
      getRowLabel,
      trigger,
      isPending,
    }),
    [safeActions, getRowId, getRowActions, getRowLabel, trigger, isPending],
  );

  return (
    <DataTableActionsContext.Provider
      value={value as unknown as DataTableActionsContextValue<never>}
    >
      {children}
      <DataTableActionsConfirmDialog
        command={confirming}
        inFlight={isPending}
        onConfirm={confirm}
        onCancel={cancel}
      />
    </DataTableActionsContext.Provider>
  );
}
src/components/data-table/data-table-actions/confirm-dialog.tsx
"use client";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { ActionDescriptor } from "@/lib/actions/types";
import { LoaderCircle } from "lucide-react";
import * as React from "react";
import { interpolate } from "./utils";

/** The command awaiting the user's answer, as the dialog sees it. */
export type ConfirmingCommandView = {
  action: ActionDescriptor;
  /** How many rows the user was shown. */
  count: number;
  /** Selected rows the action does not apply to. */
  skipped: number;
  /** `filter` scope applies to every matching row, not a selection. */
  scope: "ids" | "filter";
  /** Set when the server answered `count_mismatch`. */
  actual?: number;
};

export function describeCommand(command: ConfirmingCommandView): {
  title: string;
  description: string | null;
} {
  const { action, count, skipped, scope, actual } = command;

  if (actual !== undefined) {
    return {
      title: `${action.label}: the matching set changed`,
      description: `You were shown ${count} ${plural(count, "row")}; the server now counts ${actual}. Apply to ${actual} ${plural(actual, "row")} anyway?`,
    };
  }

  const title = interpolate(
    action.confirm ?? `${action.label} {count} ${plural(count, "row")}?`,
    { count },
  );
  const parts: string[] = [];
  if (scope === "filter") {
    parts.push("Applies to every row matching the current filters.");
  }
  if (skipped > 0) {
    parts.push(
      `${skipped} selected ${plural(skipped, "row")} ${skipped === 1 ? "does" : "do"} not qualify and will be skipped.`,
    );
  }
  return { title, description: parts.length > 0 ? parts.join(" ") : null };
}

function plural(n: number, noun: string): string {
  return n === 1 ? noun : `${noun}s`;
}

export function DataTableActionsConfirmDialog({
  command,
  inFlight = false,
  onConfirm,
  onCancel,
}: {
  /** Open while non-null. */
  command: ConfirmingCommandView | null;
  /**
   * The confirmed request is on the wire. The dialog stays open with both
   * buttons disabled until it settles — the owner closes it by clearing
   * `command`, or swaps in a `count_mismatch` view.
   */
  inFlight?: boolean;
  onConfirm: () => void;
  onCancel: () => void;
}) {
  // Keep the last command on screen while the dialog animates out: `command`
  // is cleared before the exit transition ends, and an empty title flickers.
  const [view, setView] = React.useState(command);
  if (command !== null && command !== view) setView(command);

  const copy = view ? describeCommand(view) : null;
  const destructive = view?.action.variant === "destructive";

  return (
    <AlertDialog
      open={command !== null}
      onOpenChange={(open) => {
        // Escape and outside clicks don't abandon a request already sent.
        if (!open && !inFlight) onCancel();
      }}
    >
      <AlertDialogContent size="sm">
        <AlertDialogHeader>
          <AlertDialogTitle>{copy?.title}</AlertDialogTitle>
          {copy?.description ? (
            <AlertDialogDescription>{copy.description}</AlertDialogDescription>
          ) : null}
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={inFlight}>Cancel</AlertDialogCancel>
          <AlertDialogAction
            variant={destructive ? "destructive" : "default"}
            disabled={inFlight}
            aria-busy={inFlight || undefined}
            onClick={(event) => {
              // Radix closes on click; the owner closes once the request lands.
              event.preventDefault();
              onConfirm();
            }}
          >
            {inFlight ? (
              <LoaderCircle aria-hidden className="size-4 animate-spin" />
            ) : null}
            {view?.actual !== undefined
              ? "Apply anyway"
              : (view?.action.label ?? "Confirm")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
src/components/data-table/data-table-actions/bar.tsx
"use client";

import { DataTableContext } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import * as React from "react";
import { useDataTableActions } from "./provider";
import { actionsForScope, partitionRows } from "./utils";

/**
 * Bulk buttons for a selection. Drop it into `DataTableFloatingBar`:
 *
 * ```tsx
 * <DataTableFloatingBar>
 *   {({ rows }) => <DataTableActionsBar rows={rows} />}
 * </DataTableFloatingBar>
 * ```
 *
 * Structurally typed on `{ original }` so it accepts TanStack rows without
 * depending on the table.
 */
export function DataTableActionsBar<TData>({
  rows,
}: {
  rows: readonly { original: TData }[];
}) {
  const { actions, getRowId, getRowActions, trigger, isPending } =
    useDataTableActions<TData>();
  // Optional on purpose: inside `DataTableFloatingBar` the table is there and
  // the selection is cleared once the action landed — the rows it named may
  // have left the view, and a bar saying "3 selected" over nothing is wrong.
  const table = React.useContext(DataTableContext)?.table;
  const bulk = actionsForScope(actions, "bulk");
  if (bulk.length === 0) return null;

  return (
    <>
      {bulk.map((action) => {
        const { eligible, skipped } = partitionRows(
          rows,
          action.id,
          getRowActions,
        );
        // The server publishes its `maxIds`; past it the request is refused
        // as `invalid_request`, so refuse it here with a reason instead.
        const overLimit =
          action.maxIds !== undefined && eligible.length > action.maxIds;
        // Why the button is dead, in the order the user hits them. A disabled
        // button swallows pointer events, so the tooltip lives on the wrapper
        // — on the button it would never show.
        const reason = overLimit
          ? `${action.label} applies to at most ${action.maxIds?.toLocaleString()} rows at a time`
          : eligible.length === 0
            ? `Select a row this action applies to`
            : isPending
              ? "Another action is still running"
              : undefined;
        return (
          <span
            key={action.id}
            className="inline-flex"
            title={reason}
            data-reason={reason}
          >
            <Button
              size="sm"
              variant={
                action.variant === "destructive" ? "destructive" : "outline"
              }
              disabled={reason !== undefined}
              data-action={action.id}
              data-eligible={eligible.length}
              data-skipped={skipped}
              data-over-limit={overLimit ? "" : undefined}
              onClick={() =>
                trigger(
                  action,
                  {
                    scope: "ids",
                    ids: eligible.map((row) => getRowId(row.original)),
                  },
                  {
                    count: eligible.length,
                    skipped,
                    onApplied: () => table?.resetRowSelection(),
                  },
                )
              }
            >
              {action.label}
              <span className="tabular-nums opacity-70">{eligible.length}</span>
            </Button>
          </span>
        );
      })}
    </>
  );
}
src/components/data-table/data-table-actions/column.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { DataTableFeatures } from "@/lib/table/features";
import type { ColumnDef, RowData } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import { useDataTableActions } from "./provider";
import { rowScopedActions } from "./utils";

/** The cell: a menu of the row-scoped actions this row is stamped with. */
export function DataTableActionsCell<TData>({
  row,
}: {
  row: { original: TData };
}) {
  const { actions, getRowId, getRowActions, getRowLabel, trigger, isPending } =
    useDataTableActions<TData>();
  const available = rowScopedActions(actions, getRowActions(row.original));
  if (available.length === 0) return null;
  const id = getRowId(row.original);
  // Never the row id: it is the internal key the wire uses, which for a
  // composite or opaque key reads as noise. Hosts name the row themselves.
  const label = getRowLabel?.(row.original);

  return (
    <div
      className="flex items-center justify-center"
      // The row itself opens the sheet on click and on Enter. Both bubble up
      // from the trigger — and, through React's tree, from the portalled menu
      // items — so both stop here. Only the keys that open the row are
      // stopped; everything else keeps bubbling to the row, the table, and
      // any app-level shortcut listening above them.
      onClick={(event) => event.stopPropagation()}
      onKeyDown={(event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.stopPropagation();
        }
      }}
    >
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            variant="ghost"
            size="icon-xs"
            className="size-5"
            aria-label={label ? `Actions for ${label}` : "Row actions"}
          >
            <MoreHorizontal />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          {available.map((action) => (
            <DropdownMenuItem
              key={action.id}
              // One action at a time, as in the bulk bar: a second trigger
              // while one is on the wire races the first for the shared
              // confirmation dialog.
              disabled={isPending}
              variant={
                action.variant === "destructive" ? "destructive" : "default"
              }
              onSelect={() =>
                trigger(action, { scope: "ids", ids: [id] }, { count: 1 })
              }
            >
              {action.label}
            </DropdownMenuItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}

/**
 * A column that renders `DataTableActionsCell`. Append it to the generated
 * columns; it needs a `DataTableActionsProvider` above the table. The column
 * is hideable — pair it with a `columnVisibility` default of `{ [id]: false }`
 * to keep it hidden until enabled from the view options.
 */
export function createActionsColumn<TData extends RowData>(
  options: { id?: string; size?: number } = {},
): ColumnDef<DataTableFeatures, TData> {
  const { id = "actions", size = 40 } = options;
  return {
    id,
    header: () => <span className="sr-only">Actions</span>,
    cell: ({ row }) => <DataTableActionsCell row={row} />,
    enableSorting: false,
    enableHiding: true,
    enableResizing: false,
    size,
    minSize: size,
    maxSize: size,
    meta: { label: "Actions", kind: "actions" },
  };
}
src/components/data-table/data-table-actions/utils.ts
import {
  ROW_ACTIONS_KEY,
  type ActionDescriptor,
  type ActionErrorCode,
  type ActionRequest,
  type ActionResponse,
  type ActionScope,
} from "@/lib/actions/types";

/**
 * Replace `{name}` placeholders. Unknown names are left as-is.
 *
 * `{one|other}` picks a form by `vars.count` — `"Delete {count} {log|logs}?"`
 * reads "Delete 1 log?" and "Delete 3 logs?". Descriptors travel as JSON, so
 * this is the only way copy can pluralise without a function.
 *
 * Whitespace around the forms is formatting, not copy: `{log | logs}` is the
 * same marker written with room to breathe, so the picked form is trimmed
 * rather than leaking a stray space into the sentence.
 */
export function interpolate(
  template: string,
  vars: Record<string, string | number>,
): string {
  return template.replace(
    /\{(\w+)\}|\{([^{}|]*)\|([^{}|]*)\}/g,
    (match, name: string | undefined, one: string, other: string) => {
      if (name !== undefined) {
        return Object.hasOwn(vars, name) ? String(vars[name]) : match;
      }
      return Number(vars.count) === 1 ? one.trim() : other.trim();
    },
  );
}

/** Read the server's `_actions` stamp off a row. Missing means "none". */
export function rowActionsOf(row: unknown): string[] {
  if (typeof row !== "object" || row === null) return [];
  const value = (row as Record<string, unknown>)[ROW_ACTIONS_KEY];
  return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
}

export function actionsForScope(
  actions: readonly ActionDescriptor[],
  scope: ActionScope,
): ActionDescriptor[] {
  return actions.filter((action) => action.scope.includes(scope));
}

/** Row-scoped actions the row is stamped with, in descriptor order. */
export function rowScopedActions(
  actions: readonly ActionDescriptor[],
  rowActionIds: readonly string[],
): ActionDescriptor[] {
  return actionsForScope(actions, "row").filter((action) =>
    rowActionIds.includes(action.id),
  );
}

/**
 * Split a selection into the rows an action applies to and the rest.
 *
 * A bulk button stays enabled while *any* selected row qualifies — an
 * intersection would make bulk useless on a mixed list — and the request
 * carries only the eligible ids, with the skipped count surfaced in the
 * confirmation.
 */
export function partitionRows<TRow extends { original: unknown }>(
  rows: readonly TRow[],
  actionId: string,
  getRowActions: (row: TRow["original"]) => string[],
): { eligible: TRow[]; skipped: number } {
  const eligible = rows.filter((row) =>
    getRowActions(row.original).includes(actionId),
  );
  return { eligible, skipped: rows.length - eligible.length };
}

export function newCommandId(): string {
  const c = globalThis.crypto;
  if (c && typeof c.randomUUID === "function") return `cmd_${c.randomUUID()}`;
  return `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}

/** The server said no. `code` mirrors `ActionError.error`. */
export class ActionRequestError extends Error {
  readonly code: ActionErrorCode;
  readonly status: number;
  /** Present on `count_mismatch`. */
  readonly actual?: number;

  constructor(code: ActionErrorCode, status: number, actual?: number) {
    super(code.replace(/_/g, " "));
    this.name = "ActionRequestError";
    this.code = code;
    this.status = status;
    if (actual !== undefined) this.actual = actual;
  }
}

const KNOWN_CODES: ReadonlySet<string> = new Set<ActionErrorCode>([
  "unknown_action",
  "scope_not_allowed",
  "invalid_request",
  "count_mismatch",
  "forbidden",
  "failed",
]);

/** One POST. Throws `ActionRequestError` for any non-2xx. */
export async function postAction(
  href: string,
  request: ActionRequest,
  fetcher: typeof fetch = fetch,
): Promise<ActionResponse> {
  const response = await fetcher(href, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(request),
  });

  let json: unknown = null;
  try {
    json = await response.json();
  } catch {
    // A non-JSON body is handled by the status check below.
  }

  if (!response.ok) {
    const body = (json ?? {}) as { error?: unknown; actual?: unknown };
    const code =
      typeof body.error === "string" && KNOWN_CODES.has(body.error)
        ? (body.error as ActionErrorCode)
        : "failed";
    const actual = typeof body.actual === "number" ? body.actual : undefined;
    throw new ActionRequestError(code, response.status, actual);
  }

  // A 2xx without a numeric `applied` is not "applied to 0" — it is a route
  // that does not speak the contract (204, an HTML page from a proxy, …).
  const body = (json ?? {}) as { applied?: unknown };
  if (typeof body.applied !== "number") {
    throw new ActionRequestError("failed", response.status);
  }
  return { applied: body.applied };
}

More from data-table-filters

Browse all components in this library →