1. Why think about widget state at all
In a typical React app, you’re used to having local state and API requests — at most, some Zustand/Redux. Everything revolves around the user’s browser.
In a ChatGPT App, the situation is different. Your widget is just a thin UI layer over three other entities:
- the ChatGPT model, which decides when to call your App at all and what arguments to pass to it;
- an MCP server/backend that stores the real data and executes business logic;
- the chat context where all of this lives and can be reopened in an hour, a day, or a week.
So “where the state lives” isn’t an academic question — it’s highly practical. If you put everything into React state, the user will lose their selection with the slightest chat change. If you stuff everything into widgetState, the model will read tons of JSON and happily hallucinate about it. If, on the other hand, you try to keep everything on the server and re-fetch every pixel, it will be slow and expensive.
The official recommendations explicitly split ChatGPT App state into three classes: business data, ephemeral UI state, and durable cross‑session state. That’s where we’ll start.
2. The state map in a ChatGPT App
The Apps SDK documentation describes three types of state. It’s convenient to keep them in mind as a single table:
| State type | Where it lives | Lifecycle | Examples |
|---|---|---|---|
| Business data (authoritative) | MCP server / your backend | Long: days, weeks, years | tasks, orders, products |
| UI state (ephemeral) | Inside a specific widget | As long as the widget instance lives | selected card, sort order, expanded accordion |
| Cross‑session state (durable) | Your backend / storage | Across sessions and chats | saved filters, workspace, pinned board |
Important: authoritative data should remain on the server, not in the widget. The widget receives a snapshot of that data via tools (MCP tools) and renders it, layering its local UI state on top.
In this lecture, we focus on what the widget actually sees:
- toolInput — input arguments of the invoked tool;
- toolOutput — structuredContent from the server (primary data);
- toolResponseMetadata — service metadata _meta, visible only to the widget;
- widgetState — saved UI state that ChatGPT stores with the message.
3. What exactly reaches the widget: ToolInput, ToolOutput, Metadata, WidgetState
These three state types in a ChatGPT App are reflected in concrete fields that the platform places on window.openai and passes into SDK hooks. In practice, you’ll read them via React hooks, but it’s useful to know the precise definitions.
toolInput
This is an object with the tool’s arguments that the model passed when invoking it.
For example, the user writes:
“Pick gift ideas for a woman aged 30, budget 100 dollars.”
The model decides to call your tool gift_search with the arguments:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
This is exactly the object you’ll see in toolInput inside the widget. It stores the initial scenario settings — the reason your App was launched in the first place.
toolOutput
This is the structuredContent returned by your MCP server/backend when the tool executes.
Usually it’s JSON like:
{
"gifts": [
{ "id": "1", "title": "Iceland travel guide", "price": 45 },
{ "id": "2", "title": "E-book on travel", "price": 20 }
],
"total": 2
}
toolOutput is the primary data source for rendering. Official guidance emphasizes that the model reads this field verbatim, so keep it compact and clear.
toolResponseMetadata
This is the _meta from the tool response, also available via window.openai as toolResponseMetadata. The documentation notes that the contents of _meta are visible only to the widget; the model does not receive them.
Typical examples:
- internal IDs from your system;
- UI flags (for example, “was there a cache”);
- service messages for debugging.
In short: toolOutput is “what to say to the user and the model,” while _meta is “what only the widget and logs need.”
widgetState
This is a JSON object where ChatGPT stores a snapshot of a specific widget’s UI state between renders.
Its properties:
- it lives on the ChatGPT side and is bound to a specific message/widgetId;
- it is restored when this same message is reopened;
- it’s visible to both the widget and the model (data from widgetState enters the LLM context);
- it’s size‑limited to roughly 4k tokens, so you can’t dump everything into it or store huge lists.
Important: widgetState is not a place for secrets. Neither tokens nor PII should be stored there, because the model will see them and the platform doesn’t position it as secure storage.
4. Local React state: where it’s still needed
Despite all the magic around toolOutput and widgetState, inside the widget you still write ordinary React with useState, useReducer, useRef, etc. The only difference is that:
- local state lives as long as the specific render/iframe lives;
- the model doesn’t see it at all;
- when the widget unmounts (the user goes to another chat, a re-render, an update), local state disappears.
Local state is great for:
- instant things — hover, selected tab, open dropdown;
- form input until “Continue”/“Save” is pressed;
- temporary flags like isSubmitting or isTooltipOpen.
A mini‑example inside our training App GiftGenius — a gift idea helper:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
Until we press “Confirm selection,” this is an excellent candidate for local state. But as soon as we want the selection to survive widget refreshes, we should think about widgetState.
5. widgetState: the widget’s memory between renders
widgetState is the widget’s “memory” that the platform itself persists. On every important UI action you can call setWidgetState, and ChatGPT will save this JSON together with the message. On the next render of this same widget (for example, the user scrolled back in history and then returned), the SDK will restore this object and pass it to you.
Strictly speaking, you could poke window.openai.widgetState and window.openai.setWidgetState directly, but in this lecture we stick to the recommended path — React hooks on the SDK layer.
The useWidgetState hook
One of these hooks wraps widgetState. It:
- takes the initial value either from window.openai.widgetState or from a provided defaultState;
- subscribes to updates from the host;
- on each of your setWidgetState calls, syncs the new value upward via window.openai.setWidgetState.
A typical usage inside a widget component (syntax might differ slightly in your template, but the idea is the same):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
Now uiState will be restored even after the user:
- collapses/expands the chat;
- switches to another conversation and returns;
- reloads the page (if the platform decides to restore this widget).
Example: remember the selected gift
Let’s take the list of gifts from toolOutput and remember the selected gift in widgetState so it doesn’t get lost.
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>
);
Here’s the key point: setUiState doesn’t just change local React state; it also calls window.openai.setWidgetState under the hood, if available.
If the user later presses a follow‑up under this widget, ChatGPT can continue the dialog with the same widgetId and the same widgetState, and the model will see which gift was selected.
6. Reading tool data in React: useWidgetProps and the like
So that each component doesn’t dig into window.openai.toolOutput manually, the Apps SDK provides another useful layer — the useWidgetProps hook. It takes toolOutput from the global, gives you a typed object, and optionally mixes in default values.
Simplified signature looks like this:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
In other words, it just returns toolOutput as type T.
Suppose our MCP tool returns the following structuredContent:
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
The widget can read it like this:
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>No suitable ideas yet. Try a different query.</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
</ul>
);
}
There are several good practices here:
- we don’t assume that toolOutput is already present — we provide a default value;
- we handle an empty list carefully;
- we don’t access window.openai directly — everything goes through the hook.
7. Synchronizing the UI with toolOutput: loading, empty data, errors
In the real world, toolOutput doesn’t always arrive instantly, and it isn’t always “pretty.” The Apps SDK docs explicitly recommend thinking in terms of three states: loading, normal data, error/empty.
The simplest pattern:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>Loading gift ideas…</div>;
}
if (data.error) {
return <div>Error: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Nothing matched your criteria.</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
This approach works well with the fact that the server and model can re‑invoke the tool, and you’ll receive a new toolOutput. The widget will simply receive the new value through useWidgetProps and re‑render.
In the overall flow it looks like this:
User → request
↓
Model → calls MCP tool
↓
Server → computes, hits DB/integrations, returns structuredContent and _meta
↓
ChatGPT → puts structuredContent into toolOutput
↓
Widget → renders UI from toolOutput + widgetState
The official server guide draws almost the same diagram “User → Model → MCP tool → widget iframe,” where toolOutput is the main input for the widget.
8. Multi‑step scenario: the current step in widgetState
Our GiftGenius is unlikely to be limited to a single card. Most often, you’ll want a multi‑step “wizard”: gather preferences first, then decide on a budget, and finally propose concrete options.
A logical way to store the wizard’s step number is in widgetState. That’s exactly what the docs and examples recommend.
A two‑step mini‑wizard example:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
Budget, $
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>Searching for gifts up to {state.budget} $…</div>
{/* we could already render toolOutput with gifts here */}
</div>
);
Interesting points here:
- on first render, step equals 1, the user enters a budget;
- after onBlur, we update widgetState to { step: 2, budget: … };
- on the next render (including a minute later or when reopening this message), the widget immediately lands on step 2 with the saved budget.
In a more advanced version, on the second step you’d already launch a tool via useCallTool, pass in the budget, and read the result from toolOutput. But that points to the tools module (Module 4); today the main point is where we keep the step information.
9. What goes where: the “thin UI, thick backend” pattern
Let’s summarize the roles:
- authoritative data (the list of gifts, order statuses) lives on the server and arrives via toolOutput;
- temporary visual stuff (whether an accordion is expanded, the current contents of unfinished input) lives in local React state;
- durable UI decisions within a single widget (current step, selected item, sort order) live in widgetState;
- long‑term user preferences across chats (favourite gift category, last currency) live in your backend as persistent state.
It’s tempting to make a “big object of everything,” put it into widgetState, and relax. But that’s a bad idea. The docs emphasize that the state you pass through widgetState fully enters the model’s context and should be light and mostly about UI.
The same applies to toolOutput: put exactly the data that both the widget and the model need to explain to the user what happened. Huge trees, binary blobs, raw responses from other APIs — all of that is a direct path to strange and expensive model replies.
Insight
Inside a ChatGPT widget, you cannot rely on classic client identification mechanisms. Cookies are effectively unavailable: the widget loads as a third‑party resource in the ChatGPT sandbox, and modern browsers block third‑party cookies by default. Because of this, any attempt to save state via cookies won’t work.
Experimentally verified: localStorage works great; you can rely on it when designing your applications.
10. A small end‑to‑end example: GiftGenius with durable selection
Let’s assemble a mini‑widget that:
- reads data from toolOutput;
- saves the user’s selection in widgetState;
- handles empty data gracefully.
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>One moment, finding ideas…</div>;
}
if (data.error) {
return <div>Error: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Unfortunately, we didn’t find anything. Try a different query.</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>
);
}
This code is already pretty close to a real widget:
- if the tool is still running, we see “finding ideas”;
- if the server returned an error — we honestly show it;
- if there are no gifts — we correctly handle an empty result;
- the selected gift is remembered in widgetState, and the model can use it in the next steps of the dialog.
Next, you can add “Continue with this gift” buttons (follow‑up), invoke new tools, etc., relying on the fact that the choice is already in state.
In the end, good state architecture in a ChatGPT App boils down to a simple idea: business data lives on the server, the current snapshot comes via toolOutput, temporary UI lives in local useState, and the durable but message‑scoped widget context lives in widgetState. If you keep this scheme in mind and don’t try to cram “everything at once” into a single layer, the widget remains predictable for both the user and the model.
11. Common mistakes when working with Widget State, ToolInput, and ToolOutput
Error #1: Storing business data in widgetState instead of on the server.
Sometimes you want to stash a whole list of entities in widgetState to avoid calling the server again. This is bad for two reasons: you duplicate authoritative data (the server and the widget can diverge), and you bloat the model’s context, because widgetState enters it in full. It’s better to keep the real data on the server and return a fresh toolOutput as a snapshot.
Error #2: Putting secrets or PII into widgetState.
Since the model sees the contents of widgetState and it isn’t meant as secure storage, you must not place tokens, logins, emails, phone numbers, or other confidential information there. Such things should live on the server; at most, store a record ID in widgetState that you’ll use via MCP.
Error #3: Assuming toolOutput is always present and always correct.
A widget that blindly dives into toolOutput.gifts[0] will break sooner or later: the tool can return an error, an empty array, or a changed structure. You should explicitly handle “loading,” “empty,” and “error” states — and only then render normally.
Error #4: Copying toolOutput into local state unnecessarily.
It’s tempting to do const [data, setData] = useState(toolOutput) and then live only with this data. As a result, you get a duplicated source of truth: when a new toolOutput arrives, local state won’t know about it, and the UI will keep showing stale data. It’s better to read toolOutput directly from useWidgetProps or derive state (mapping, filtering) during render without duplicating the entire object.
Error #5: Using only local useState where widgetState is needed.
A classic bug: you build a small wizard, keep currentStep in local state, everything tests fine. Then the user scrolls the chat, returns — and suddenly it’s back to the first step. The reason is simple: local state didn’t survive the widget unmount. For steps that are important to the scenario, use widgetState so the platform restores them with the message.
Error #6: Trying to access window.openai directly in every component.
Technically this works, but you get a hard dependency on a global, code that’s difficult to debug, and hand‑written event subscriptions. Official materials and examples recommend using the hook layer (useWidgetProps, useWidgetState, useOpenAiGlobal), which encapsulates all the details and is easier to test.
Error #7: Ignoring the message‑scoped nature of widgets.
If the user doesn’t press a follow‑up and just writes a new chat message, ChatGPT creates a new widget instance with a new widgetId and an empty widgetState. Scenarios that rely on the “eternal” memory of a single widget start acting weird. Here you either need to store cross‑session context on the server or build UX around follow‑ups and explicit continuation of the scenario.
GO TO FULL VERSION