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, itsentity_idstring, ornull. 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 toexecute. 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 typedresultfor that single shape. If you omitTKind,resultdefaults to a union of every possible response body (AnyActionResult), which you can narrow with"<key>" in result. See Typing the response.execute(parameters)— callexecutefrom 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 tonullifactionIdisnull, or the SDK isn’t yet initialized. You canawait execute(parameters)or fire and forget. The same error is written toerrorstate either way, so a render-time error message will appear even without atry/catch.isExecuting—truebetween the call and its resolution. UseisExecutingto disable the trigger and prevent double-clicks.result— the response body, ornullbefore the first call and afterreset().error— the last thrown error, ornull. See Error handling.reset()— clearsresultanderror.
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:
<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
- ActionKind
- AnyActionResult
- ActionResultForKind
- ActionResultForCreate
- ActionResultForUpdate
- ActionResultForDelete
- ActionResultForBulk
- ActionResultForSql
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.
Related
Read docs for other versions of Metabase.