metabase-data-app-actions について
Metabase Data App の開発者向けに、設定済みの SQL 書き込みアクションを `useAction` で実行する方法を説明します。スキーマに基づくパラメーター、結果やエラーの処理、実行後のデータ更新を扱います。
主な機能
- 01`useAction` によるアクション実行
- 02スキーマに基づく入力型の対応
- 03フィールドとリクエストエラーの表示
- 04実行後の関連データ更新
- 05実行状態と結果の処理
活用シーン
- 01フォームからレコードを作成・更新
- 02確認後に SQL 書き込みを実行
- 03パラメーターや SQL のエラーを調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ metabase/ metabase --skill metabase-data-app-actionsインストール後、Claude Code で/metabase-data-app-actionsを入力して読み込まれたか確認します。
詳細
Metabase データアプリからアクションをトリガーする
Metabase のアクションとは、データウェアハウスに対して保存された、パラメータ化された SQL 書き込み処理のことです — INSERT、UPDATE、または DELETE です。アクションはあらかじめ Metabase インスタンス上で設定され、そのパラメータと権限もすでに設定されています。データアプリの役割は、ユーザーが何らかの操作を行ったとき — ボタンをクリックする、フォームを送信する、破壊的な操作の確認プロンプトを承認する — に、適切なパラメータを付けてアクションを呼び出すことです。
メンタルモデル
- アプリが実行できるすべてのアクションは、スキーマ内に
schema.actions.<actionName>としてtype: "query"付きで現れます。 - 各アクションは
parametersリストを公開します。各パラメータにはslug(データアプリが送信するキー)、jsType("string"/"number"/"Date"/"boolean"/"unknown")、および省略可能なrequiredフラグがあります。 action.parametersを使って、どのフィールドをレンダリングして送信すべきかを把握します。結果はresult["rows-affected"]を報告します。これを使って軽量な確認を行い、その後、ページがすでに表示しているテーブル/クエスチョン/クエリのデータを更新します。
スキーマに含まれるもの(そして含まれないもの)
アクションのエントリは、型付きスキーマにアクションが含まれている場合にのみ生成されます。アクションを呼び出すコードを書く前に、スキーマが include-actions=true で生成されていることを確認してください。database=<name-or-id>&include-actions=true を指定すると、Metabase はそのデータベースのアクションのみを含めます。
アクションを呼び出すコードを書く前に、schema.actions の下で利用可能なものを列挙してください。スキーマは、アプリが実行できるアクションの完全なカタログです。そこに存在しないものは、データアプリにとって存在しないも同然です。アプリがスキーマにない書き込みを必要とする場合は、ユーザーに、モデルなしのクエリアクションとしてそれを作成するよう依頼し(POST /api/action に type: "query" を指定し、model_id は付けない。Metabase UI で作成されたアクションはモデルに属するため、決して一覧に表示されません)、アクションが有効なデータベース上で作成したうえで、スキーマを再生成してください。
フック
import { useAction } from "@metabase/embedding-sdk-react/data-app";
const { execute, isExecuting, result, error, reset } = useAction(MyAction);
@metabase/embedding-sdk-react/data-appからインポートします。 このエントリのuseActionはデータアプリ用の形式です。defineAction(...)のエクスポートまたはnullのみを受け付け、それ以外は受け付けません。メインエントリのuseActionは汎用 SDK フックであり、プレーンオブジェクトや生の id も受け取るため、コンパイル時に定義を強制できません。データアプリでは、生の id は実行時に「was passed touseActionas a raw id」で失敗します。- 引数は、アプリのルートレベルの
actions/ディレクトリ(package.jsonの隣、src/の下には決して置かない、トピックごとに 1 つの<topic>.action.tsファイル)で宣言されたdefineAction(...)のエクスポートそのものです —MyAction.copiedActionEntityIdではなくMyActionを渡します。データアプリでは、このフックはそれ以外を受け付けません。インラインの{ action: ... }オブジェクト、スキーマエントリ、または定義のスプレッドコピーは、Property 'definedWithDefineAction' is missingでコンパイルに失敗します。これを修正するには、キャストではなく、フックの位置でdefineAction(...)を呼び出すのでもなく(これはコンパイルは通りますが、コピーを取得できません)、actions/にエクスポートを追加します。その後、本番ビルドでは、リポジトリのcollections/data_apps/の下にある、アプリ自身のコレクション内で、そのcopiedActionEntityIdが指定するコピーが実行されます。開発プレビューでは引き続き作成済みのアクションが実行されるため、アプリはそのリソースが存在する前でも動作します。これらのコピーの書き込みについては、アプリのコレクションのファイルを書くためのデータアプリのガイダンス(スキルディスカバリーを使用)で説明されています。schema.actions.<action>やその.idを渡さないでください。このエントリはコンパイルエラーになり、データアプリでは実行時に生の id が「was passed touseActionas a raw id」で拒否されます。 - ジェネリクスを書かないでください。 定義はスキーマエントリを保持しているため、フックはパラメータオブジェクトと判別可能な
resultの両方をそこから推論します。parameters[]はキー付きオブジェクトになり(required: trueのエントリは必須キーで、各値はそのjsTypeから型付けされます)、type: "query"は種別を"sql"にします。生の id 形式はメインエントリの SDK のuseActionにのみ存在し、そこではuseAction<TParameters, TKind>(42)のように明示する必要があります。id は何も記述しないためです。データアプリは id でアクションを指定することはありません。 execute(parameters)— アクションをトリガーします。パラメータオブジェクトはパラメータのslugをキーとします。required: trueと宣言されたパラメータは必須キーで、それ以外はすべて任意です。成功時にはレスポンスボディを返し、失敗時にはスローします(エラーはレンダー時の利用者のためにerror状態にも書き込まれます)。actionIdがnullの場合、または SDK がまだ初期化されていない場合は(リクエストを行わずに)nullに解決されます — これらのケースに到達しうる場合は、呼び出し箇所をガードしてください。enabled/options引数はありません。 このフックはexecute(...)が呼び出されたときにのみ実行されるため、ゲートオプションは冗長です。アクションをスキップするには、イベントハンドラーで分岐します:const onClick = async () => { if (!user.canEdit) return; await execute({ id: orderId, discount }); };isExecuting— 呼び出しからその解決までの間、trueになります。これを使ってボタンのdisabledを制御し、ユーザーがダブルクリックして重複リクエストを送信できないようにします。result— レスポンスボディで、TKindによって判別されます(TKindが省略された場合はAnyActionResultのユニオンになります)。最初の呼び出しの前とreset()の後はnullです。軽量な確認(result?.["rows-affected"])に使用し、その後、周囲のデータを更新します — アクションの実行後 を参照してください。error— 最後にスローされたエラーで、型はActionExecuteError | nullです。キャストなしでフィールドを直接読み取れます:error?.data?.message、error?.status、error?.isCancelled。reset()—resultとerrorをnullにクリアして戻します。ユーザーが成功を確認した後やエラーを閉じた後に便利です。
このフックはマウント時に自動発火しません。 アクションは、データアプリが明示的に execute(...) を呼び出したときにのみ実行されます。
正規の使用例 — 行を作成するフォーム
import { useAction } from "@metabase/embedding-sdk-react/data-app";
import { CreatePerson } from "../../actions/people.action";
function AddPersonForm({ onCreated }: { onCreated: () => void }) {
const { useState } = React;
const { execute, isExecuting, error, reset } = useAction(CreatePerson);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
try {
await execute({ name, email }); // typed: keys match parameter slugs
setName("");
setEmail("");
onCreated(); // ← let the parent refresh dependent data
} catch {
// error is captured into hook state for render-time display
}
}
return (
<form onSubmit={onSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit" disabled={isExecuting || !name || !email}>
{isExecuting ? "Saving…" : "Add"}
</button>
</form>
);
}
エラーメッセージの表示
execute(...) が失敗したときは、両方のエラー層を表に出してください。フックの error は ActionExecuteError | null 型です — 形状は { status?, data: { message?, errors? }, isCancelled } です。error.data.message はリクエスト全体の失敗です。error.data.errors はパラメーターのスラッグをキーとするフィールド単位のバリデーションマップ ({ <slug>: <message> })、またはリクエスト全体の失敗の場合は {} です — これは SQL 自体のすべての失敗に当たります。キャストせずに両方を直接読み取ってください:
const fieldErrors = error?.data.errors ?? {};
{error ? (
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>
{error.data.message ?? "Action failed."}
</pre>
) : null}
{action.parameters.map((parameter) => {
const fieldError = fieldErrors[parameter.slug];
return (
<label key={parameter.slug}>
{parameter.displayName}
<input
aria-invalid={Boolean(fieldError)}
style={{ borderColor: fieldError ? "#dc2626" : undefined }}
/>
{fieldError ? <div role="alert">{fieldError}</div> : null}
</label>
);
})}
<pre> (または white-space: pre-wrap を持つ任意の要素) を使用してください — メッセージには意味を持つ改行が含まれています (ドライバーのエラーには SQL が独自の行として含まれます)。<span> はそれらを 1 つの壁のようなテキストに潰してしまいます。
例: CHARACTER(2) カラムに 9 文字の値を送信すると、次のようになります
Value too long for column "STATE CHARACTER(2)": "'dadasdasd' (9)";
SQL statement:
UPDATE "PUBLIC"."PEOPLE" SET … WHERE "PUBLIC"."PEOPLE"."ID" = 1 [22001-214]
この文字列全体が error.data.message です。そのままレンダリングしてください。
やってはいけないこと:
error.data.messageの代わりに"Failed"/"Something went wrong"/String(error)をレンダリングする — ユーザーは、自分が持っている唯一の修正情報を失ってしまいます。- バリデーション失敗時に
error.data.messageだけをレンダリングする —error.data.errors[parameter.slug]は、多くの場合「どのフィールドが、なぜか」という実行可能な詳細です。 - メッセージを「より親しみやすい」バージョンに言い換える。生の H2/Postgres/SQL エラーは、どんな書き換えよりも実行可能です。
アクション実行後 — UI の誠実さを保つ
アクションが成功したとき、ユーザーが見ているデータは古い可能性があります。明示的に更新しなければ、リストは依然として古い行を表示し、統計タイルは依然として古い件数を表示し、ユーザーがちょうど編集した行は依然として古い値を表示します。アクションは成功したのに、UI は嘘をついています — そして警告はありません。
ルールはシンプルで絶対です: アクションが正常に解決した後は、そのアクションが変更し得た画面上のあらゆるデータを更新しなければなりません。
パラメーターのマッピング
スキーマアクションの各 parameters[] エントリは、slug、displayName、jsType、そして省略可能な required を公開します。Data App の役割は次のとおりです:
-
execute({ … })のオブジェクトキーは、パラメーターのslug文字列と一致します。 それらをリテラルな文字列キーとして使用してください — フックが定義から導出する形状はコンパイル時にタイプミスを拒否しますが、それは引数がdefineAction(...)のエクスポートである場合に限られます (裸の id では機能しません)。 -
jsTypeから適切な入力typeを選んでください — そうすればブラウザーが正しい UX と組み込みの型変換を提供します。エージェントがスラッグ名から入力タイプを勝手に推測することはありません (スラッグが"phone"であってもjsType: "string"→type="text"です)。jsType入力要素 "string"<input type="text" …>"number"<input type="number" …>"boolean"<input type="checkbox" …>(または Mantine のSwitch/Checkbox)"Date"<input type="date" …>(時間要素が想定される場合は"datetime-local")"unknown"<input type="text" …>— ベストエフォートで、呼び出し側で型変換 -
execute({ … })の値の型はjsTypeと一致します。jsType: "number"のパラメーターは文字列ではなくnumberを求めます。type="number"の場合、入力は.valueAsNumberに対しては数値を出力しますが、<input>.valueは依然として文字列です — 後者を読み取る場合は呼び出し側で型変換してください (Number(input))。 -
required: trueのパラメーターは省略できません。 これは TS 型にも反映されます: 必須キーは必須です。 -
displayNameはラベル用です。決してキーとして使用しないでください。
スラッグは、アクションの作成者が Metabase で SQL パラメーターに付けた名前そのままです。スキーマが信頼できる唯一の情報源です。
フォーム側のバリデーション — メインアプリと一致させる
スキーマは parameter.required(および型強制用の jsType)を公開しています。これが現時点での完全なバリデーション契約であり、Metabase 標準のアクション実行フォームと一致します。長さチェックなし、min/max なし、フォーマットチェックなし — それ以上に細かいものは送信後に BE エラーとして返ってきます(エラーメッセージの表示を参照)。
スキーマが唯一の信頼できる情報源です。 parameter.required を読み取って、それだけを組み込んでください。フィールド名が "email" だからといって required を追加したり、なんとなくで「name」を 100 文字に制限したり、スラッグから type="email" を設定したりしないでください。スキーマに記載がなければ、そのフィールドは制約なしです。
フォームが無効な間は送信ボタンを無効にしてください。 ブラウザネイティブの判定結果から制御することで、required だけが唯一のルールとなります。
const [isFormValid, setIsFormValid] = useState(false);
const onFormChange = (e: React.FormEvent<HTMLFormElement>) =>
setIsFormValid(e.currentTarget.checkValidity());
<form onSubmit={...} onChange={onFormChange}>
<input required={parameter.required} />
<button type="submit" disabled={isExecuting || !isFormValid}>Create</button>
</form>
手書きの !name || !email チェックは不要です。
デバッグチェックリスト
アクションが成功したように見えるのに画面が更新されない場合、または呼び出しが 400 で失敗する場合:
await execute(...)の後にresult、error、isExecutingをログ出力して、リクエストが実際に送信され成功したことを確認します。useActionがactions/からインポートしたdefineAction(...)のエクスポートで呼び出されていることを確認します。スキーマのエントリ、インラインオブジェクト、またはスプレッドコピーを渡すとコンパイルエラーになります。その.idを渡すとコンパイルは通りますが、executeが型付けされず、実行時に「was passed touseActionas a raw id」で失敗します。コンポーネント内でdefineAction(...)を呼び出してもコンパイルは通りますが、アプリのコレクションにコピーが存在しないため、デプロイされたアプリでは「This action has no copy」がスローされます。同じエラーは、actions/内の定義にcopiedActionEntityIdが欠けている場合にも発生します。execute({ ... })に渡すオブジェクトをログ出力します。すべてのキーはschema.actions.<action>.parametersのパラメータslugと一致する必要があり、すべての値は宣言されたjsTypeと一致する必要があります。- アクションが書き込む行を読み取る、画面上のすべてのデータビューをリストアップします。それぞれのデータフックがアクショントリガーの上位にマウントされていることを確認して、そのリフレッシュコールバックを下に渡せるようにします。
- リフレッシュコールバックが
await execute(...)の後に呼び出されること、およびリフレッシュ自体が await されていることを確認します。複数のリフレッシュが適用される場合は、それらがまとめて await されている(Promise.all)ことを確認します。 - 期待するアクションがスキーマに記載されていない場合、そのアクションはインスタンス上で定義されていないか、スキーマファイルが古いかのどちらかです。スキーマを再生成してください。
よくある間違い
@metabase/embedding-sdk-react/data-appではなく@metabase/embedding-sdk-reactからuseActionをインポートしている。メインエントリのフックは汎用 SDK のもので何でも受け入れるため、定義のチェックが実行されません。- アクションをルートレベルの
actions/以外の場所で宣言している。フックのインライン、コンポーネント内でdefineAction(...)にラップ、またはsrc/actions/配下など。actions/内の定義だけが、アプリのコレクションファイルと照合してチェックされます。 defineAction(...)のエクスポートではなく、schema.actions.<action>.idをuseActionに渡している。するとexecuteは任意のRecord<string, unknown>を受け入れ、{ wrongKey: 1 }のようなタイポがすり抜け、データアプリは実行時に生の id を拒否して「was passed touseActionas a raw id」と表示します。- アクションの成功後にリフレッシュするのを忘れている。UI はエラーも警告もなく古いデータを表示し続けます。
- 実際のバックエンドのメッセージではなく
"Failed"/"Something went wrong"/String(error)をレンダリングしている。常にerror.data.message/error.data.errors(ユーザーが必要とする診断情報はそこにあります)を取り出して、そのままレンダリングしてください — エラーメッセージの表示 を参照。 - リフレッシュコールバックを
awaitせずに呼び出している。新しいデータが届く前にモーダルが閉じたりフォームがクリアされたりして、ユーザーはしばらく古いビューを見つめることになります。 - アクションをトリガーするのと同じコンポーネント内でデータを読み込んでいる。フックがトリガーより下にあるため、そのリフレッシュコールバックを接続できません。データフックを親に持ち上げ、そのリフレッシュコールバックをトリガーに渡してください。
- スラッグの
jsTypeが"number"または"boolean"のときに、生の<input>文字列をパラメーター値として渡している。呼び出し側で型変換してください。 - 前のリクエストが処理中なのにトリガーを発火させている。フックから
disabled={isExecuting}を制御してください。 - リフレッシュを省略するために、アクションの
resultを直接ローカルリストの先頭に追加している。サーバーが埋めるデフォルト値(自動 ID、タイムスタンプ、計算列、派生結合列)は、クライアント側の推測とは食い違います。 - インスタンスが公開していない動作をユーザーが求めたために、アクションをでっち上げている。スキーマは存在するもののカタログです。ギャップを伝え、呼び出しを偽装しないでください。
- 直接
fetch("/api/action/...")に手を伸ばしている。サンドボックスは Metabase オリジンへの生のネットワーク呼び出しをブロックします(生のfetch/XHR はdata_app.yamlで宣言された外部のallowed_hostsに対してのみ機能します)。アクションへの唯一の経路はuseActionです。