1. なぜウィジェットの状態を考えるのか
ふつうの React アプリでは、ローカルステートと API リクエストがあり、あっても Zustand/Redux 程度—すべてはブラウザ中心に回ります。
ChatGPT App は様相が異なります。あなたのウィジェットは、次の3つの実体の上に載る薄い UI レイヤーにすぎません。
- ChatGPT のモデル(いつ App を呼ぶか、どんな引数を渡すかを決める);
- MCP サーバ/バックエンド(実データを保持しビジネスロジックを実行する);
- チャットのコンテキスト(すべてがそこに存在し、1時間後・翌日・1週間後に再オープンされうる)。
したがって「状態をどこに置くか」は机上の空論ではなく、とても実践的なテーマです。すべてを React ステートだけに入れておくと、チャットが少し変わっただけでユーザーの選択が失われます。逆にすべてを widgetState に突っ込むと、モデルが巨大な JSON を読み、誤推論(ハルシネーション)を起こしやすくなります。かといって、すべてをサーバに持たせて毎回取り直すと遅くて高コストです。
公式の推奨は、ChatGPT App の状態を明確に3分類します。ビジネスデータ、エフェメラルな UI ステート、メッセージに紐づく持続的な状態。ここから始めましょう。
2. ChatGPT App における状態マップ
Apps SDK のドキュメントは3種類のステートを説明しています。1枚の表として頭に入れておくと便利です。
| 状態の種類 | どこに存在するか | ライフサイクル | 例 |
|---|---|---|---|
| Business data (authoritative) | MCP サーバ/自前のバックエンド | 長期間: 日・週・年単位 | タスク、注文、商品 |
| UI state (ephemeral) | 該当ウィジェット内 | そのウィジェットインスタンスが生存している間 | 選択中のカード、ソート、折りたたみの開閉状態 |
| Cross‑session state (durable) | 自前のバックエンド/ストレージ | セッションやチャットをまたいで | 保存済みフィルター、workspace、pinned board |
重要: authoritative なデータはウィジェットではなくサーバ側に置くべきです。ウィジェットはツール(MCP tools)経由でそのスナップショットを受け取り、そこにローカル UI ステートを重ねてレンダリングします.
本講義では、ウィジェットから見える領域にフォーカスします。
- toolInput — 呼び出されたツールの入力引数;
- toolOutput — サーバからの structuredContent(主要データ);
- toolResponseMetadata — ウィジェットのみに見える _meta メタデータ;
- widgetState — ChatGPT がメッセージとともに保持する保存済み UI ステート。
3. ウィジェットに渡されるもの: ToolInput, ToolOutput, Metadata, WidgetState
これら3種のステートは、プラットフォームが window.openai に載せ、SDK のフックへ渡す具体的なフィールドとして表れます。 実際には React フック経由で受け取ることが多いですが、正確な定義を知っておくと役に立ちます。
toolInput
これは、モデルがツールを呼ぶ際に渡した引数(tool)のオブジェクトです。
たとえばユーザーが次のように書いたとします。
「30歳女性向けのギフトアイデアを、予算100ドルで提案して」。
モデルはあなたのツール gift_search を次の引数で呼ぶと判断します。
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
まさにこのオブジェクトが、ウィジェット内の toolInput に入っています。ここにはシナリオの元設定—App を起動した理由—が保存されます。
toolOutput
これは、ツール実行時にあなたの MCP サーバ/バックエンドが返した structuredContent です。
ふつうは次のような JSON です。
{
"gifts": [
{ "id": "1", "title": "アイスランド旅行ガイド", "price": 45 },
{ "id": "2", "title": "旅行の電子書籍", "price": 20 }
],
"total": 2
}
toolOutput はレンダリングの主なデータソースです。公式でも強調されていますが、モデルはこのフィールドを逐語的に読みます。したがって、コンパクトで明快に保ってください。
toolResponseMetadata
ツール応答の _meta です。window.openai から toolResponseMetadata として参照できます。ドキュメントには、_meta の内容はウィジェットにのみ見え、モデルには渡らないと明記されています。
典型例:
- 自システムの内部 ID;
- UI 用のフラグ(例: キャッシュの有無);
- デバッグ用のサービスメッセージ。
端的に言えば、toolOutput は「ユーザーとモデルに見せるべきもの」、_meta は「ウィジェットとログだけが必要とするもの」です。
widgetState
これは、ChatGPT が特定のウィジェットの UI ステートのスナップショットをレンダー間で保持するための JSON オブジェクトです。
特性:
- ChatGPT 側に保存され、特定の message/widgetId に紐づく;
- 同じメッセージを再オープンした際に復元される;
- ウィジェットとモデルの双方から見える(widgetState のデータは LLM コンテキストに入る);
- サイズはおよそ 4k トークンに制限。巨大なリストなどを詰め込むのは不可。
重要: widgetState は秘密情報の置き場所ではありません。トークンや PII を入れてはいけません。モデルから見えますし、プラットフォーム自体もセキュアストレージとしては想定していません。
4. ローカル React ステート: 依然として必要な場面
toolOutput や widgetState があっても、ウィジェット内部では依然として通常の React を書きます。 useState、useReducer、useRef などです。 違いは次のとおりです。
- ローカルステートは、そのレンダー/iframe が生きている間だけ存在する;
- モデルからは一切見えない;
- ウィジェットのアンマウント(他のチャットへ移動、再描画、更新)で消える。
ローカルステートが適するのは次のような場合です。
- 瞬間的なもの—hover、選択中タブ、開いているドロップダウン;
- 「続行」/「保存」を押すまでのフォーム入力;
- isSubmitting や isTooltipOpen のような一時的なフラグ。
学習用 App「GiftGenius」(ギフト選定アシスタント)のミニ例:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
「確定」を押す前であれば、これはローカルステートの好例です。ただし、選択をウィジェットの更新をまたいで保持したいなら、widgetState を検討すべきです。
5. widgetState: レンダー間のウィジェットの記憶
widgetState はプラットフォームが保存してくれるウィジェットの「記憶」です。UI の重要な操作ごとに setWidgetState を呼べば、ChatGPT がその JSON をメッセージと一緒に保存します。後で同じウィジェットが再レンダーされると(履歴を遡って戻ってきたなど)、SDK がこのオブジェクトを復元して渡してくれます。
厳密には window.openai.widgetState と window.openai.setWidgetState を直接叩くこともできますが、 本講義では推奨どおり—SDK の React フック経由で扱います。
フック useWidgetState
このフックは widgetState をラップします。具体的には:
- 初期値を window.openai.widgetState または渡された defaultState から取得;
- ホストからの更新に購読;
- あなたが setWidgetState を呼ぶたび、window.openai.setWidgetState を通じて上位へ同期。
ウィジェットコンポーネント内での典型的な利用例(テンプレートにより細部は異なるかもしれませんが、考え方は同じ):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
これで uiState は次のような場合でも復元されます。
- チャットを折りたたみ/展開した;
- 別のスレッドに移動して戻ってきた;
- ページを更新した(プラットフォームがこのウィジェットを復元する判断をした場合)。
例: 選んだギフトを記憶する
toolOutput のギフト一覧を表示し、選択したギフトを widgetState に保存して失われないようにします。
type Gift = { id: string; title: string; price: number };
const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(() => ({
selectedId: null,
}));
return (
<ul>
{gifts.map(gift => (
<li
key={gift.id}
style={{
fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
}}
onClick={() => setUiState({ selectedId: gift.id })}
>
{gift.title}
</li>
))}
</ul>
);
ここでの重要点: setUiState はローカルの React ステートを変更するだけでなく、内部で window.openai.setWidgetState も(利用可能なら)呼び出します。
後でユーザーがこのウィジェットの follow‑up を押すと、ChatGPT は同じ widgetId と同じ widgetState で対話を続ける可能性があり、モデルはどのギフトが選ばれたかを把握できます。
6. React でツールデータを読む: useWidgetProps など
各コンポーネントで毎回 window.openai.toolOutput に直接アクセスしないように、Apps SDK には便利なフック useWidgetProps があります。これはグローバルから toolOutput を取得し、型付きのオブジェクトとして返し、必要ならデフォルト値も混ぜられます。
簡略化したシグネチャは次のとおりです:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
要するに、内部的には toolOutput が T 型としてそのまま返ってきます。
仮に MCP ツールが次の structuredContent を返すとします。
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
ウィジェットでは次のように読み取れます。
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>条件に合うアイデアはまだありません。別のリクエストを試してください。</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
</ul>
);
}
ここには良い実践がいくつか含まれています。
- toolOutput が必ずあると仮定せず、デフォルト値を用意する;
- 空リストを丁寧に扱う;
- window.openai に直接触らず、フック経由で扱う。
7. toolOutput と UI の同期: ロード・空・エラー
現実世界では toolOutput が即座に来るとも、常に「きれい」だとも限りません。Apps SDK のドキュメントは、ロード中・正常データ・エラー/空の3状態を意識して設計するよう明言しています。
最小パターン:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>ギフトのアイデアを読み込み中…</div>;
}
if (data.error) {
return <div>エラー: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>条件に合致するものは見つかりませんでした。</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
このアプローチは、サーバやモデルがツールを呼び直して新しい toolOutput を返すケースとも相性が良いです。ウィジェットは useWidgetProps 経由で新しい値を受け取り、再レンダーされます。
全体のフローは次のようになります。
ユーザー → リクエスト
↓
モデル → MCP tool を呼び出す
↓
サーバ → 計算・DB/外部統合にアクセスし、structuredContent と _meta を返す
↓
ChatGPT → structuredContent を toolOutput に格納
↓
ウィジェット → toolOutput + widgetState から UI をレンダリング
サーバ側ガイドでもほぼ同じ図「User → Model → MCP tool → widget iframe」が描かれており、toolOutput がウィジェットの主入力であると説明されています。
8. マルチステップシナリオ: 現在のステップは widgetState に
GiftGenius は1枚のカードで完結しないでしょう。多くの場合はウィザード形式になります。まず嗜好を集め、次に予算を決め、最後に候補を提示する、といった流れです。
ウィザードのステップ番号を保持する最も素直な方法は、widgetState に置くことです。ドキュメントやサンプルでもちょうどそうするよう推奨されています。
2ステップのミニウィザード例:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
予算($)
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>予算 {state.budget} $ 以内のギフトを検索中…</div>
{/* ここで toolOutput のギフトをレンダリングしてもよい */}
</div>
);
ここでの注目点:
- 初回は step が 1 で、ユーザーが予算を入力;
- onBlur 後に widgetState を { step: 2, budget: … } に更新;
- 次回のレンダー(1分後やメッセージ再オープン時でも)には、保存済みの予算を持ったステップ2から始まる。
さらに進んだ版では、2ステップ目で useCallTool を使ってツールを起動し、budget を渡して toolOutput の結果を読むでしょう。これはツールの講義(モジュール4)への前振りですが、本日の主題は「ステップ情報をどこに置くか」です。
9. 何をどこに置くか: パターン「薄い UI、厚いバックエンド」
役割の分担をまとめます。
- authoritative データ(ギフト一覧、注文ステータス)はサーバに置き、toolOutput でスナップショットとして返す;
- 一時的な見た目の状態(折りたたみの開閉、未確定入力の中身)はローカルの React ステートに置く;
- 1つのウィジェット内で持続させたい UI の選択(現在のステップ、選択中アイテム、ソート順)は widgetState に置く;
- チャットをまたぐ長期設定(好みのカテゴリ、最後に使った通貨)は自前バックエンドの persistent state に置く。
ときに「全部入りの巨大オブジェクト」を作って widgetState に入れたくなるものですが、これは悪手です。widgetState はそのままモデルのコンテキストに入るため、軽量で主に UI 寄りの情報に限るべきだとドキュメントでも強調されています。
同様に toolOutput には、ウィジェットとモデルがユーザーへ説明するために必要なちょうどのデータを入れましょう。巨大ツリー、バイナリ、外部 API の生レスポンスなどは、モデルの奇妙で高コストな応答へ一直線です。
Insight
ChatGPT のウィジェット内では、古典的なクライアント識別メカニズムに依存できません。Cookie は実質的に使えません。ウィジェットは ChatGPT のサンドボックス内でサードパーティリソースとして読み込まれ、最新のブラウザは既定で third‑party cookies をブロックするため、Cookie で状態を残す試みは機能しません。
実験で確認済み: localStorage は問題なく動作します。アプリ設計時に依拠して構いません。
10. 小さなエンドツーエンド例: GiftGenius で選択を持続させる
次を満たすミニウィジェットを組み立てましょう。
- toolOutput からデータを読む;
- ユーザーの選択を widgetState に保存する;
- 空データを丁寧に扱う。
import {
useWidgetProps,
useWidgetState,
} from "@openai/chatgpt-apps-sdk/react";
type Gift = { id: string; title: string; price: number };
type GiftToolOutput = { gifts: Gift[]; currency: string; error?: string };
export function GiftWidget() {
const data = useWidgetProps<GiftToolOutput | null>(() => null);
const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(
() => ({ selectedId: null })
);
if (data === null) {
return <div>少々お待ちください、アイデアを選定中…</div>;
}
if (data.error) {
return <div>エラー: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>残念ながら見つかりませんでした。別のリクエストを試してください。</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li
key={gift.id}
style={{
fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
cursor: "pointer",
}}
onClick={() => setUiState({ selectedId: gift.id })}
>
{gift.title} — {gift.price} {data.currency}
</li>
))}
</ul>
);
}
このコードは実用的なウィジェットにかなり近いものです。
- ツールがまだ実行中なら「アイデアを選定中…」が見える;
- サーバがエラーを返したら正直に表示する;
- ギフトがなければ空結果を正しく扱う;
- 選んだギフトは widgetState に記憶され、モデルは次の対話ステップでそれを利用できる。
あとは「このギフトで続行」(follow‑up)ボタンの追加、新しいツールの起動などを重ねれば、選択がすでに状態に入っていることを前提に進められます。
結論として、ChatGPT App における良い状態アーキテクチャは単純な原則に収れんします。ビジネスデータはサーバに置き、スナップショットは toolOutput で受け取り、短命の UI はローカルの useState に、メッセージに紐づくが持続させたい文脈は widgetState に置く。これを守って「全部を1層に詰め込まない」ことが、ユーザーにもモデルにも予測可能なウィジェットを保つ鍵です。
11. Widget State、ToolInput、ToolOutput でよくあるミス
エラー №1: ビジネスデータを widgetState に置き、サーバに置かない。
再度サーバを呼ばないために、エンティティの丸ごとリストを widgetState に保存したくなることがあります。これは二重に悪手です。authoritative データが二重化(サーバとウィジェットで乖離)し、widgetState がそのままモデルのコンテキストに入り膨張します。実データはサーバに置き、最新のスナップショットだけを toolOutput として返しましょう。
エラー №2: widgetState に秘密情報や PII を入れる。
widgetState の内容はモデルから見え、保護ストレージとしては設計されていません。トークン、ログイン、e‑mail、電話番号などの機微情報を入れてはいけません。そうした情報はサーバに置き、widgetState には最大でもレコード ID を入れて、以降は MCP 経由で操作しましょう。
エラー №3: toolOutput が常に存在し常に正しいと決めつける。
toolOutput.gifts[0] に無条件でアクセスするウィジェットはいずれ壊れます。ツールがエラーや空配列を返すことも、構造が変わることもあります。「ロード中」「空」「エラー」を明示的に扱い、その後に通常レンダーへ進むのが推奨です。
エラー №4: 不要なのに toolOutput をローカルステートに複製する。
次のように書きたくなることがあります。 const [data, setData] = useState(toolOutput) 。以後はこの data だけで生きる、という発想です。結果は「真のソース」の重複です。新しい toolOutput が来てもローカルステートはそれを知らず、UI は古いデータを表示し続けます。useWidgetProps から toolOutput を直接読むか、レンダー内で派生状態(マッピングやフィルタ)を作る程度にとどめ、オブジェクト全体の複製は避けましょう。
エラー №5: useState だけで済ませるべきでない場面で widgetState を使わない。
典型的なバグです。小さなウィザードで currentStep をローカルステートに持ち、テストでは動きます。しかしユーザーがチャットをスクロールして戻ると、また最初のステップに戻ってしまう。理由は簡単で、ローカルステートはウィジェットのアンマウントを生き延びないからです。シナリオ上重要なステップは widgetState にし、プラットフォームに復元させましょう。
エラー №6: window.openai へ各コンポーネントから直接アクセスする。
形式上は動きますが、グローバルに強く依存し、デバッグしづらく、イベント購読も手書きになります。公式の資料やサンプルでは、 (useWidgetProps、useWidgetState、useOpenAiGlobal) といったフック層の利用が推奨されています。これらは詳細をカプセル化し、テストしやすくなります。
エラー №7: ウィジェットが message スコープであることを考慮しない。
ユーザーが follow‑up を押さず、新しいメッセージを書いた場合、ChatGPT は新しいウィジェットインスタンスを作り、新しい widgetId と空の widgetState を割り当てます。「1つのウィジェットが永遠に記憶する」前提のシナリオは挙動が怪しくなります。セッションをまたぐ文脈はサーバに保持するか、follow‑up と明示的な継続を前提に UX を設計しましょう。
GO TO FULL VERSION