Modular embedding SDK - actions

Modular embedding SDK is only available on Pro and Enterprise plans (both self-hosted and on Metabase Cloud).

With the useAction hook, you can trigger an action when someone clicks a button or submits a form in your app.

The hook handles the HTTP request, exposes loading and error state as React state, and types the parameters the action expects. Basic CRUD and custom SQL actions are supported; HTTP-type actions are not. Always trigger actions through useAction — calling POST /api/action/:id/execute directly with fetch may be blocked in sandboxed embedding contexts.

Triggering an action with useAction

const { execute, isExecuting, result, error, reset } = useAction<
  TParameters,
  TKind   // optional — drives the typed `result` shape
>(actionId);
  • actionId — the action’s numeric id, its entity_id string, or null. Find the numeric id in Metabase by opening the action editor and copying it from the URL.
  • TParameters — a TypeScript type describing the parameters object that will be passed to execute. Keys are the action’s parameter slugs (the names shown in the action editor).
  • TKind (optional) — the action’s kind literal. Pass one of "create", "update", "delete", "bulk", or "sql" to get a typed result for that single shape. If you omit TKind, result defaults to a union of every possible response body (AnyActionResult), which you can narrow with "<key>" in result. See Typing the response.
  • execute(parameters) — call execute from an event handler to trigger the action. The hook doesn’t auto-fire on mount. Resolves to the response body on success, throws on failure, or resolves to null if actionId is null, or the SDK isn’t yet initialized. You can await execute(parameters) or fire and forget. The same error is written to error state either way, so a render-time error message will appear even without a try/catch.
  • isExecutingtrue between the call and its resolution. Use isExecuting to disable the trigger and prevent double-clicks.
  • result — the response body, or null before the first call and after reset().
  • error — the last thrown error, or null. See Error handling.
  • reset() — clears result and error.

API Reference

Example button to trigger an action

This button calls a custom SQL action to apply a discount to an order:

import { useState } from "react";

import {
  MetabaseProvider,
  defineMetabaseAuthConfig,
  useAction,
} from "@metabase/embedding-sdk-react";

const authConfig = defineMetabaseAuthConfig({
  metabaseInstanceUrl: "https://your-metabase.example.com",
});

// A Metabase action's numeric id (an `entity_id` string also works).
// To get the id, open the action editor and copy it from the URL,
// or fetch it via `GET /api/action`.
const SET_DISCOUNT_ACTION_ID = 42;

// Declare the parameter shape the action expects. Keys are the parameter
// slugs (the names shown in Metabase's action editor), not internal UUIDs.
type SetDiscountParameters = {
  id: number;
  discount: number;
};

function SetDiscountButton({ orderId }: { orderId: number }) {
  const { execute, isExecuting, result, error } =
    useAction<SetDiscountParameters>(SET_DISCOUNT_ACTION_ID);
  const [discount, setDiscount] = useState(0.1);

  const onClick = async () => {
    try {
      await execute({ id: orderId, discount });
    } catch {
      // The thrown error is also captured into `error` state, below.
    }
  };

  return (
    <div>
      <label>
        Discount:&nbsp;
        <input
          type="number"
          step="0.05"
          value={discount}
          onChange={(e) => setDiscount(Number(e.target.value))}
        />
      </label>

      <button onClick={onClick} disabled={isExecuting}>
        {isExecuting ? "Applying…" : "Apply discount"}
      </button>

      {result ? <span>Done.</span> : null}

      {error ? (
        <pre style={{ whiteSpace: "pre-wrap" }}>
          {error.data.message ?? "Action failed."}
        </pre>
      ) : null}
    </div>
  );
}

export default function App() {
  return (
    <MetabaseProvider authConfig={authConfig}>
      <SetDiscountButton orderId={1} />
    </MetabaseProvider>
  );
}

Parameter keys

Send parameters keyed by slug. The parameter’s display name (e.g. "Discount") won’t work; you must use the slug (e.g., "discount").

Parameter value types

You can pass strings, number, and boolean parameters. For dates, pass an ISO 8601 string. Examples:

await execute({
  name: "Jane", // string parameter
  age: 30, // number parameter
  is_active: true, // boolean parameter
  birth_date: "1995-04-22", // date parameter, ISO format
  created_at: "2024-01-15T10:00:00Z", // timestamp parameter, ISO with `Z` for UTC
});

Dates and timezones

When the target column is TIMESTAMP without timezone, send the ISO value either without a timezone offset, or with the Z suffix:

await execute({ created_at: "2024-01-15T10:00:00Z" }); // 10:00 UTC — stored as 10:00

A timezone-offset value like "2024-01-15T10:00:00+05:00" is typically converted to UTC by the database driver, so the stored wall-clock shifts (the example above would store 05:00:00). Exact behavior varies by warehouse — check your driver if precise timezone handling matters. For TIMESTAMP WITH TIME ZONE columns the offset is preserved as the same instant; for DATE columns timezone is irrelevant.

When the value comes from a browser-local date picker (which often returns the user’s local TZ), normalize before sending:

const picked = new Date(datePickerValue);
await execute({ created_at: picked.toISOString() }); // always Z-suffixed

If the string can’t be parsed, the database driver throws and the message surfaces via error.data.message (see Error handling).

Typing the response

The action’s kind drives the shape of result. Pass it as the second generic to useAction and result gets typed automatically:


version: v0.63 has_magic_breadcrumbs: true show_category_breadcrumb: true show_title_breadcrumb: true category: Embedding title: ActionKind source_url: >- https://github.com/metabase/metabase/blob/master/docs/embedding/sdk/api/snippets/ActionKind.md layout: new-docs latest: true —

type ActionKind = "create" | "update" | "delete" | "bulk" | "sql";

Flat public kind union. Maps onto the backend’s namespaced row/* + bulk/* implicitKind and the query type value, but exposes a simpler five-value surface to callers: create / update / delete always refer to a single row, bulk covers any bulk variant, and sql covers custom SQL actions (the backend’s query-type action).

Action kind What it covers result shape
"create" Single-row insert (basic action) { "created-row": Record<string, RowValue> }
"update" Single-row update { "rows-updated": readonly RowValue[] }
"delete" Single-row delete { "rows-deleted": readonly RowValue[] }
"bulk" Any bulk variant (bulk create / update / delete) { success: boolean; "rows-created"?: number; "rows-updated"?: number; "rows-deleted"?: number }
"sql" Custom SQL action { "rows-affected": number }

API Reference

Example with a known kind:

const SET_DISCOUNT_ACTION_ID = 42;

type SetDiscountParameters = { id: number; discount: number };

function SetDiscountButton({ orderId }: { orderId: number }) {
  const { execute, result } = useAction<SetDiscountParameters, "sql">(
    SET_DISCOUNT_ACTION_ID,
  );

  const onClick = async () => {
    await execute({ id: orderId, discount: 0.1 });
  };

  // result is typed `{ "rows-affected": number } | null` — no cast.
  const affected = result?.["rows-affected"];

  return (
    <button onClick={onClick}>
      Apply 10% discount{affected != null ? ` (${affected} rows)` : ""}
    </button>
  );
}

Reading the result

It’s common not to read the result at all (see After an action succeeds, you must refresh the data).

But if you do read the result, specify TKind if you know the action’s result up front.

If you don’t supply TKind, the result defaults to AnyActionResult, which is the union of every possible response body. TypeScript knows the result is one of the five known shapes, just not which one. You can then narrow with the in operator:

const { execute, result } = useAction<SetDiscountParameters>(
  SET_DISCOUNT_ACTION_ID,
);

const onClick = async () => {
  await execute({ id: orderId, discount: 0.1 });
};

let summary = "Apply discount";
if (result && "rows-affected" in result) {
  // `result["rows-affected"]` is typed `number` here
  summary = `${result["rows-affected"]} rows affected`;
} else if (result && "created-row" in result) {
  // `result["created-row"]` is typed `Record<string, RowValue>` here
  summary = "Row created";
}

The union default catches mistyped reads: if the type system can’t prove result has a key, it’ll error.

After an action succeeds, you must refresh the data

When an action succeeds, you’ll need to refresh any data in the UI that the action could have changed, otherwise the data on screen may be stale. There is no automatic refresh.

After execute resolves successfully, refresh a question by remounting it: keep a refreshKey in state, use it as the question’s key, and bump it after the action. The new key gives the question a fresh mount, which re-runs its query.

import { useState } from "react";

import {
  InteractiveQuestion,
  MetabaseProvider,
  defineMetabaseAuthConfig,
  useAction,
} from "@metabase/embedding-sdk-react";

const authConfig = defineMetabaseAuthConfig({
  metabaseInstanceUrl: "https://your-metabase.example.com",
});

// IDs of a saved question that lists orders and a "Mark shipped" action.
const ORDERS_QUESTION_ID = 1;
const MARK_SHIPPED_ACTION_ID = 42;

type MarkShippedParameters = {
  id: number;
};

function OrdersScreen() {
  const [refreshKey, setRefreshKey] = useState(0);

  return (
    <div>
      <InteractiveQuestion key={refreshKey} questionId={ORDERS_QUESTION_ID} />
      <MarkAllShippedButton onShipped={() => setRefreshKey((key) => key + 1)} />
    </div>
  );
}

function MarkAllShippedButton({ onShipped }: { onShipped: () => void }) {
  const { execute, isExecuting } = useAction<MarkShippedParameters>(
    MARK_SHIPPED_ACTION_ID,
  );

  const onClick = async () => {
    await execute({ id: 1 });
    // Only refresh once the action has succeeded, so the question re-queries
    // against the mutated rows.
    onShipped();
  };

  return (
    <button onClick={onClick} disabled={isExecuting}>
      {isExecuting ? "Shipping…" : "Mark order as shipped"}
    </button>
  );
}

export default function App() {
  return (
    <MetabaseProvider authConfig={authConfig}>
      <OrdersScreen />
    </MetabaseProvider>
  );
}

If a single action invalidates more than one view, drive every dependent question off the same refreshKey so one state bump remounts them all and they re-query together:

await execute({ id: orderId });
// One state bump remounts every dependent view, refreshing them together.
setRefreshKey((key) => key + 1);

Don’t try to drive the state from result directly. The response body is for confirmation (a row count, the inserted row’s primary key, etc.). You can use the result for toasts or detail-view navigation, but you still need to re-read the source to update the data on screen.

Error handling

The hook normalizes whatever the underlying network client throws into a clean, public-facing shape and types error accordingly:

Property Type
data { errors?: Record<string, string>; message?: string; }
data.errors? Record<string, string>
data.message? string
isCancelled boolean
status? number

error.status is optional: present for HTTP-level failures (4xx / 5xx), absent for transport-layer failures (offline, aborted) where no HTTP response was received. The actionable diagnostic for end users lives at error.data.message.

error.data.errors is a per-field map ({ <slug>: <message> }) when the backend reports parameter-level validation failures, keyed by the same parameter slugs you pass to execute. For whole-request failures (like a foreign-key constraint), it’s an empty {} and the message lives in error.data.message.

API Reference

For SQL or driver errors, error.data.message often includes a newline and the failing SQL statement on the next line, so render the error message inside an element with white-space: pre-wrap (a <pre> is fine). A <span> collapses the newlines into one wall of text.

The basic example above renders error.data.message with a static fallback when no message was provided:

{error ? (
  <pre style={{ whiteSpace: "pre-wrap" }}>
    {error.data.message ?? "Action failed."}
  </pre>
) : null}

Display the error message verbatim. Don’t replace the message with a generic “Something went wrong”. The raw SQL / validation / permission error is what tells the person how to fix their input.

Read docs for other versions of Metabase.

Was this helpful?

Thanks for your feedback!
Want to improve these docs? Propose a change.