| title | Modular embedding SDK - actions |
|---|---|
| summary | Trigger Metabase actions from your embedded application with the `useAction` hook. |
{% include plans-blockquote.html feature="Modular embedding SDK" sdk=true %}
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.
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.
This button calls a custom SQL action to apply a discount to an order:
{% include_file "{{ dirname }}/snippets/actions/basic.tsx" %}Send parameters keyed by slug. The parameter's display name (e.g. "Discount") won't work; you must use the slug (e.g., "discount").
You can pass strings, number, and boolean parameters. For dates, pass an ISO 8601 string. Examples:
{% include_file "{{ dirname }}/snippets/actions/parameter-values.tsx" snippet="primitives-and-dates" %}When the target column is TIMESTAMP without timezone, send the ISO value either without a timezone offset, or with the Z suffix:
{% include_file "{{ dirname }}/snippets/actions/date-picker.tsx" snippet="timestamp-utc" %}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:
{% include_file "{{ dirname }}/snippets/actions/date-picker.tsx" snippet="normalize-date" %}If the string can't be parsed, the database driver throws and the message surfaces via error.data.message (see Error handling).
The action's kind drives the shape of result. Pass it as the second generic to useAction and result gets typed automatically:
{% include_file "{{ dirname }}/api/snippets/ActionKind.md" %}
| 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 } |
- ActionKind
- AnyActionResult
- ActionResultForKind
- ActionResultForCreate
- ActionResultForUpdate
- ActionResultForDelete
- ActionResultForBulk
- ActionResultForSql
Example with a known kind:
{% include_file "{{ dirname }}/snippets/actions/typed-response.tsx" snippet="known-kind" %}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:
{% include_file "{{ dirname }}/snippets/actions/typed-response.tsx" snippet="narrow-result" %}The union default catches mistyped reads: if the type system can't prove result has a key, it'll error.
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.
{% include_file "{{ dirname }}/snippets/actions/with-refresh.tsx" %}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:
{% include_file "{{ dirname }}/snippets/actions/parallel-refresh.tsx" snippet="parallel-refresh" %}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.
The hook normalizes whatever the underlying network client throws into a clean, public-facing shape and types error accordingly:
{% include_file "{{ dirname }}/api/snippets/ActionExecuteError.md" snippet="properties" %}
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.
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:
{% include_file "{{ dirname }}/snippets/actions/basic.tsx" snippet="error-render" %}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.