1. Tại sao cần nghĩ về trạng thái của widget
Trong một ứng dụng React thông thường, bạn đã quen với: có state cục bộ, có các request API, cùng lắm là một Zustand/Redux nào đó. Mọi thứ xoay quanh trình duyệt của người dùng.
Trong ChatGPT App thì khác. Widget của bạn chỉ là một lớp UI mỏng nằm trên ba thực thể khác:
- mô hình ChatGPT, thứ quyết định khi nào gọi App của bạn và truyền những tham số nào;
- máy chủ MCP/backend, nơi lưu dữ liệu thật và thực thi nghiệp vụ;
- ngữ cảnh cuộc trò chuyện, nơi mọi thứ tồn tại và có thể được mở lại sau một giờ, một ngày hoặc một tuần.
Vì vậy “trạng thái nằm ở đâu” không phải câu hỏi học thuật, mà rất thực tiễn. Nếu dồn hết vào state React, chỉ cần chat thay đổi một chút là người dùng mất lựa chọn. Nếu bạn nhét tất cả vào widgetState, mô hình sẽ đọc hàng đống JSON và dễ “ảo tưởng” dựa trên đó. Còn nếu ngược lại, cố cất hết trên server và yêu cầu lại từng pixel — sẽ chậm và tốn kém.
Khuyến nghị chính thức chia trạng thái của ChatGPT App thành ba lớp: dữ liệu kinh doanh, UI phù du và trạng thái bền vững qua phiên. Hãy bắt đầu với điều đó.
2. Bản đồ trạng thái trong ChatGPT App
Tài liệu về Apps SDK mô tả ba kiểu state. Dễ nhớ nhất là giữ chúng trong đầu dưới dạng một bảng:
| Loại trạng thái | Nằm ở đâu | Vòng đời | Ví dụ |
|---|---|---|---|
| Business data (authoritative) | Máy chủ MCP / backend của bạn | Lâu: ngày, tuần, năm | nhiệm vụ, đơn hàng, sản phẩm |
| UI state (ephemeral) | Bên trong widget cụ thể | Chừng nào instance của widget còn sống | thẻ đã chọn, sắp xếp, spoiler đã mở |
| Cross‑session state (durable) | Backend của bạn / kho lưu trữ | Giữa các phiên và các cuộc chat | bộ lọc đã lưu, workspace, pinned board |
Quan trọng: dữ liệu authoritative phải ở lại trên server, không phải trong widget. Widget nhận snapshot dữ liệu đó qua tools (MCP tools) và render, rồi phủ lên đó state UI cục bộ của mình.
Trong bài này, chúng ta tập trung vào những gì widget nhìn thấy:
- toolInput — tham số đầu vào của tool được gọi;
- toolOutput — structuredContent từ server (dữ liệu chính);
- toolResponseMetadata — metadata phục vụ _meta, chỉ widget nhìn thấy;
- widgetState — trạng thái UI được lưu, mà ChatGPT giữ cùng với message.
3. Cụ thể widget nhận gì: ToolInput, ToolOutput, Metadata, WidgetState
Ba kiểu state này trong ChatGPT App phản ánh vào các trường cụ thể mà nền tảng đặt trong window.openai và truyền vào các hook của SDK. Trên thực tế, bạn sẽ lấy chúng qua các hook của React, nhưng biết định nghĩa chính xác vẫn hữu ích.
toolInput
Đây là đối tượng chứa tham số của tool mà mô hình truyền vào khi gọi nó.
Ví dụ, người dùng viết:
“Gợi ý ý tưởng quà tặng cho phụ nữ 30 tuổi, ngân sách 100 đô.”
Mô hình quyết định gọi tool gift_search của bạn với các tham số:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
Chính đối tượng này bạn sẽ thấy trong toolInput bên trong widget. Ở đó lưu các thiết lập gốc của kịch bản — lý do mà App của bạn được khởi chạy.
toolOutput
Đây là structuredContent mà máy chủ MCP/backend của bạn trả về khi thực thi tool.
Thông thường là JSON như sau:
{
"gifts": [
{ "id": "1", "title": "Cẩm nang du lịch Iceland", "price": 45 },
{ "id": "2", "title": "Sách điện tử về du lịch", "price": 20 }
],
"total": 2
}
Chính toolOutput là nguồn dữ liệu chính để render. Tài liệu nhấn mạnh: mô hình đọc trường này theo đúng nghĩa đen, vì vậy hãy giữ nó gọn nhẹ và dễ hiểu.
toolResponseMetadata
Đây là _meta từ phản hồi của tool, cũng có sẵn qua window.openai dưới tên toolResponseMetadata. Tài liệu ghi chú riêng rằng nội dung _meta chỉ widget nhìn thấy, mô hình không nhận được.
Ví dụ điển hình:
- ID nội bộ từ hệ thống của bạn;
- các cờ cho UI (ví dụ, “có cache hay không”);
- thông điệp phục vụ cho debug.
Tóm lại: toolOutput — là “thứ cần nói với người dùng và mô hình”, còn _meta — là “thứ chỉ widget và log cần”.
widgetState
Đây là đối tượng JSON mà ChatGPT lưu snapshot trạng thái UI của widget cụ thể giữa các lần render.
Đặc tính của nó:
- tồn tại phía ChatGPT và gắn với message/widgetId cụ thể;
- được khôi phục khi mở lại chính message đó;
- cả widget và mô hình đều thấy (dữ liệu từ widgetState đi vào context của LLM);
- bị giới hạn dung lượng khoảng 4k token, nên không thể đổ tất cả mọi thứ vào hoặc lưu danh sách khổng lồ ở đó.
Quan trọng: widgetState không phải nơi cho bí mật. Không được đặt token hay dữ liệu PII vào đó, vì mô hình sẽ thấy chúng và bản thân nền tảng cũng không định vị nó như một kho bảo mật.
4. State cục bộ của React: khi nào vẫn cần
Dù có mọi phép màu quanh toolOutput và widgetState, bên trong widget bạn vẫn viết React bình thường với useState, useReducer, useRef, v.v. Khác biệt chỉ ở chỗ:
- state cục bộ sống bao lâu tùy thuộc vào vòng đời render/iframe cụ thể;
- mô hình hoàn toàn không nhìn thấy nó;
- khi widget bị unmount (người dùng chuyển sang chat khác, re-render, reload) state cục bộ biến mất.
State cục bộ rất phù hợp cho:
- những thứ tức thời — hover, tab đã chọn, dropdown đang mở;
- nhập form trước khi nhấn “Tiếp tục”/“Lưu”;
- các cờ tạm thời như isSubmitting hoặc isTooltipOpen.
Ví dụ nhỏ trong App học tập GiftGenius — trợ lý chọn quà:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
Cho tới khi chúng ta nhấn “Xác nhận lựa chọn”, đây là ứng viên tuyệt vời cho state cục bộ. Nhưng ngay khi muốn lựa chọn “sống sót” qua lần cập nhật widget, hãy nghĩ tới widgetState.
5. widgetState: bộ nhớ của widget giữa các lần render
widgetState là “bộ nhớ” của widget do chính nền tảng lưu. Mỗi khi có hành động UI quan trọng, bạn có thể gọi setWidgetState, và ChatGPT sẽ lưu JSON này cùng với message. Ở lần render tiếp theo của cùng widget (ví dụ, người dùng cuộn lại lịch sử chat rồi quay lại), SDK sẽ khôi phục đối tượng đó và truyền cho bạn.
Nói nghiêm ngặt, bạn có thể gọi trực tiếp window.openai.widgetState và window.openai.setWidgetState, nhưng trong bài này chúng ta theo con đường khuyến nghị — các hook React ở lớp SDK.
Hook useWidgetState
Một trong các hook đó bọc widgetState. Nó:
- lấy giá trị khởi tạo từ window.openai.widgetState hoặc từ defaultState truyền vào;
- đăng ký lắng nghe cập nhật từ host;
- mỗi lần bạn gọi setWidgetState sẽ đồng bộ giá trị mới lên trên qua window.openai.setWidgetState.
Ví dụ sử dụng điển hình trong component của widget (cú pháp có thể hơi khác tùy template, nhưng ý tưởng là vậy):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
Giờ đây uiState sẽ được khôi phục ngay cả sau khi người dùng:
- thu nhỏ/mở rộng chat;
- chuyển sang cuộc đối thoại khác rồi quay lại;
- tải lại trang (nếu nền tảng quyết định khôi phục widget này).
Ví dụ: ghi nhớ quà tặng đã chọn
Lấy danh sách quà tặng từ toolOutput và ghi nhớ món quà đã chọn trong widgetState để không bị mất.
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>
);
Ở đây có điểm quan trọng: setUiState không chỉ thay đổi state React cục bộ, mà còn gọi window.openai.setWidgetState bên dưới, nếu có sẵn.
Nếu sau này người dùng nhấn follow‑up dưới widget này, ChatGPT có thể tiếp tục hội thoại với cùng widgetId và cùng widgetState, và mô hình sẽ thấy món quà nào được chọn.
6. Đọc dữ liệu của tool trong React: useWidgetProps và tương tự
Để mỗi component không phải đụng tay vào window.openai.toolOutput, Apps SDK có một lớp hữu ích khác — hook useWidgetProps. Nó lấy toolOutput từ global, đưa cho bạn một đối tượng có kiểu và nếu muốn có thể trộn giá trị mặc định.
Chữ ký giản lược như sau:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
Tức là bên trong nó chỉ trả về toolOutput dưới kiểu T.
Giả sử tool của MCP trả về structuredContent như sau:
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
Widget có thể đọc như sau:
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>Hiện chưa có ý tưởng phù hợp. Hãy thử yêu cầu khác.</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
</ul>
);
}
Ở đây có vài thực hành tốt:
- chúng ta không mặc định rằng toolOutput luôn có — đặt giá trị mặc định;
- xử lý cẩn thận danh sách rỗng;
- không truy cập trực tiếp window.openai — mọi thứ qua hook.
7. Đồng bộ UI với toolOutput: tải, dữ liệu rỗng, lỗi
Trong thế giới thực, toolOutput không phải lúc nào cũng đến ngay và không phải lúc nào cũng “đẹp”. Tài liệu Apps SDK khuyến nghị nghĩ về ba trạng thái: tải, dữ liệu bình thường, lỗi/rỗng.
Mẫu đơn giản nhất:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>Đang tải ý tưởng quà tặng…</div>;
}
if (data.error) {
return <div>Lỗi: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Không tìm thấy gì theo tiêu chí của bạn.</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
Cách tiếp cận này phối hợp tốt với việc server và mô hình có thể gọi lại tool, và bạn sẽ nhận toolOutput mới. Khi đó widget chỉ việc nhận giá trị mới qua useWidgetProps và re-render.
Trong luồng tổng quát, trông như sau:
Người dùng → yêu cầu
↓
Mô hình → gọi MCP tool
↓
Server → tính toán, truy vấn DB/tích hợp, trả về structuredContent và _meta
↓
ChatGPT → đặt structuredContent vào toolOutput
↓
Widget → render UI từ toolOutput + widgetState
Hướng dẫn chính thức về server vẽ một sơ đồ gần như vậy “User → Model → MCP tool → widget iframe”, trong đó toolOutput là đầu vào chính cho widget.
8. Kịch bản nhiều bước: bước hiện tại trong widgetState
GiftGenius của chúng ta khó mà chỉ dừng lại ở một thẻ. Thường ta muốn “wizard” nhiều bước: trước tiên thu thập sở thích, sau đó xác định ngân sách, và cuối cùng đề xuất các phương án cụ thể.
Cách hợp lý để lưu số bước của wizard — trong widgetState. Tài liệu và ví dụ cũng khuyến nghị làm như vậy.
Ví dụ wizard nhỏ 2 bước:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
Ngân sách, $
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>Đang tìm quà tặng tới {state.budget} $…</div>
{/* ở đây chúng ta có thể render toolOutput với danh sách quà */}
</div>
);
Những điểm đáng chú ý:
- lần hiển thị đầu tiên step bằng 1, người dùng nhập ngân sách;
- sau onBlur ta cập nhật widgetState thành { step: 2, budget: … };
- ở lần render tiếp theo (kể cả sau một phút hoặc khi mở lại message này) widget sẽ ở ngay bước 2 với ngân sách đã lưu.
Ở phiên bản nâng cao hơn, bước thứ hai bạn đã gọi tool qua useCallTool, truyền budget và đọc kết quả từ toolOutput. Nhưng đó là phần của mô-đun về tools (Mô-đun 4); hôm nay điều chính yếu là chúng ta giữ thông tin về bước ở đâu.
9. Đặt cái gì ở đâu: mẫu “UI mỏng, backend dày”
Tổng hợp vai trò phân bổ:
- dữ liệu authoritative (danh sách quà, trạng thái đơn hàng) nằm trên server và đến qua toolOutput;
- những chi tiết giao diện tạm thời (spoiler có mở không, nội dung nhập dở) nằm trong state React cục bộ;
- các quyết định UI bền vững trong một widget (bước hiện tại, phần tử được chọn, sắp xếp) nằm trong widgetState;
- các thiết lập lâu dài của người dùng giữa các cuộc chat (danh mục quà yêu thích, đơn vị tiền tệ cuối cùng) nằm trong backend của bạn như persistent state.
Đôi khi rất muốn tạo một “đối tượng khổng lồ chứa tất cả”, đặt vào widgetState và yên tâm. Nhưng đó là ý tưởng tệ. Tài liệu nhấn mạnh rằng trạng thái bạn truyền qua widgetState đi thẳng vào context của mô hình và nên nhẹ, chủ yếu liên quan đến UI.
Điều tương tự áp dụng cho toolOutput: chỉ nên đặt đúng những dữ liệu cần cho cả widget và mô hình để giải thích với người dùng điều gì đã xảy ra. Cây dữ liệu lớn, blob nhị phân, phản hồi thô của các API khác — tất cả là con đường dẫn tới phản hồi kỳ lạ và tốn kém của mô hình.
Insight
Bên trong widget ChatGPT không thể dựa vào các cơ chế nhận diện client kinh điển. Cookie thực chất không khả dụng: widget được tải như tài nguyên bên thứ ba trong sandbox của ChatGPT, và các trình duyệt hiện đại chặn third‑party cookies theo mặc định. Vì vậy mọi nỗ lực lưu trạng thái qua cookie đều không hiệu quả.
Đã kiểm chứng thực nghiệm: localStorage hoạt động rất tốt, bạn có thể trông cậy vào nó khi thiết kế ứng dụng của mình.
10. Ví dụ xuyên suốt nhỏ: GiftGenius với lựa chọn bền vững
Gom lại thành một mini‑widget, cái mà:
- đọc dữ liệu từ toolOutput;
- lưu lựa chọn của người dùng trong widgetState;
- xử lý gọn gàng dữ liệu rỗng.
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>Vui lòng chờ, đang tìm ý tưởng…</div>;
}
if (data.error) {
return <div>Lỗi: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Rất tiếc, không tìm thấy gì. Hãy thử yêu cầu khác.</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>
);
}
Đoạn mã này đã khá gần với widget thực tế:
- nếu tool vẫn đang chạy, ta thấy “đang tìm ý tưởng”;
- nếu server trả lỗi — hiển thị thẳng lỗi;
- nếu không có quà — xử lý đúng trường hợp rỗng;
- món quà được chọn được ghi nhớ trong widgetState, và mô hình có thể dùng nó trong các bước tiếp theo của hội thoại.
Sau đó bạn có thể thêm nút “Tiếp tục với món quà này” (follow‑up), gọi các tool mới, v.v., dựa trên việc lựa chọn đã nằm trong state.
Cuối cùng, kiến trúc trạng thái tốt trong ChatGPT App gói gọn trong ý tưởng đơn giản: dữ liệu kinh doanh sống trên server, snapshot hiện tại đến qua toolOutput, UI tạm thời — trong useState cục bộ, còn ngữ cảnh bền vững nhưng gắn với một message của widget — nằm trong widgetState. Nếu giữ sơ đồ này trong đầu và không cố nhét “tất cả” vào một lớp, widget sẽ nhất quán với cả người dùng lẫn mô hình.
11. Các lỗi thường gặp khi làm việc với Widget State, ToolInput và ToolOutput
Lỗi số 1: Lưu dữ liệu kinh doanh trong widgetState thay vì trên server.
Đôi khi muốn lưu cả một danh sách thực thể vào widgetState để khỏi gọi server lại. Điều này không ổn vì hai lý do: bạn nhân đôi dữ liệu authoritative (server và widget có thể lệch nhau), và bạn phình to context của mô hình, vì widgetState đi vào đó nguyên vẹn. Tốt hơn là lưu dữ liệu thật trên server và trả về toolOutput mới như một snapshot.
Lỗi số 2: Nhét vào widgetState các bí mật hoặc PII.
Vì nội dung widgetState được mô hình nhìn thấy và nó không phải kho bảo mật, không được đặt token, đăng nhập, email, số điện thoại và thông tin nhạy cảm khác vào đó. Những thứ như vậy phải sống trên server, còn trong widgetState nhiều nhất chỉ lưu ID bản ghi để bạn tiếp tục làm việc qua MCP.
Lỗi số 3: Cho rằng toolOutput luôn tồn tại và luôn chính xác.
Widget mà không kiểm tra đã truy cập toolOutput.gifts[0] thì sớm muộn cũng hỏng: tool có thể trả lỗi, mảng rỗng, hoặc thay đổi cấu trúc. Khuyến nghị xử lý rõ ràng các trạng thái “tải”, “rỗng”, “lỗi”, rồi mới render bình thường.
Lỗi số 4: Sao chép toolOutput vào state cục bộ khi không cần thiết.
Đôi khi có cám dỗ làm const [data, setData] = useState(toolOutput) và sau đó chỉ dùng data. Kết quả là bạn có hai nguồn sự thật: khi toolOutput mới đến, state cục bộ sẽ không biết và UI tiếp tục hiển thị dữ liệu cũ. Tốt hơn là đọc toolOutput trực tiếp từ useWidgetProps hoặc derive‑state (map, filter) trong render, không nhân đôi cả đối tượng.
Lỗi số 5: Chỉ dùng useState cục bộ khi thực ra cần widgetState.
Lỗi kinh điển: bạn làm một wizard nhỏ, lưu currentStep trong state cục bộ, test — chạy ổn. Rồi người dùng cuộn chat, quay lại — và bỗng lại ở bước đầu. Lý do đơn giản: state cục bộ không sống sót sau khi widget bị unmount. Với các bước quan trọng trong kịch bản, hãy dùng widgetState, khi đó nền tảng sẽ khôi phục cùng với message.
Lỗi số 6: Cố truy cập window.openai trực tiếp trong mọi component.
Về hình thức thì vẫn chạy, nhưng bạn sẽ bị trói vào global, mã khó debug và phải tự viết các đăng ký sự kiện. Tài liệu và ví dụ chính thức khuyên dùng lớp hook (useWidgetProps, useWidgetState, useOpenAiGlobal), vốn đóng gói chi tiết và dễ test hơn.
Lỗi số 7: Không tính tới bản chất message‑scoped của widget.
Nếu người dùng không nhấn follow‑up mà chỉ viết message mới trong chat, ChatGPT tạo một instance widget mới với widgetId mới và widgetState trống. Những kịch bản dựa vào “bộ nhớ vĩnh viễn” của một widget sẽ hành xử kỳ lạ. Ở đây hoặc bạn lưu context bền vững qua phiên trên server, hoặc xây UX quanh follow‑up và việc tiếp tục kịch bản một cách rõ ràng.
GO TO FULL VERSION