1. Зачем вообще думать про состояние виджета
В обычном React‑приложении вы привыкли: есть локальный стейт, есть API‑запросы, максимум — какой‑нибудь Zustand/Redux. Всё крутится вокруг браузера пользователя.
В ChatGPT App ситуация другая. Ваш виджет — это всего лишь тонкий UI‑слой над тремя другими сущностями:
- моделью ChatGPT, которая решает, когда вообще звать ваш App и какие аргументы ему передать;
- MCP‑сервером/бэкендом, который хранит настоящие данные и выполняет бизнес‑логику;
- контекстом чата, в котором всё это живёт и может переоткрываться через час, день или неделю.
Поэтому «где лежит состояние» — не академический вопрос, а очень практический. Если всё складывать только в React‑стейт, при малейшем изменении чата пользователь потеряет выбор. Если вы засунете всё в widgetState, модель начнёт читать тонны JSON’а и весело галлюцинировать по его поводу. Если, наоборот, пытаться хранить всё на сервере и перезапрашивать каждый пиксель — будет медленно и дорого.
Официальные рекомендации прямо делят состояние ChatGPT App на три класса: бизнес‑данные, эфемерный UI‑стейт и долговременное кросс‑сессионное состояние. С этим и начнём.
2. Карта состояний в ChatGPT App
Документация по Apps SDK описывает три типа стейта. Удобно держать их в голове в виде одной таблицы:
| Тип состояния | Где живёт | Жизненный цикл | Примеры |
|---|---|---|---|
| Business data (authoritative) | MCP‑сервер / ваш backend | Долго: дни, недели, годы | задачи, заказы, товары |
| UI state (ephemeral) | Внутри конкретного виджета | Пока живёт экземпляр виджета | выбранная карточка, сортировка, раскрытый спойлер |
| Cross‑session state (durable) | Ваш backend / хранилище | Между сессиями и чатами | сохранённые фильтры, workspace, pinned board |
Важно: authoritative данные должны оставаться на сервере, а не в виджете. Виджет получает снимок этих данных через инструменты (MCP tools) и рендерит его, накладывая на него свой локальный UI‑стейт.
У нас в этой лекции фокус на том, что видит именно виджет:
- toolInput — входные аргументы вызванного инструмента;
- toolOutput — structuredContent от сервера (основные данные);
- toolResponseMetadata — служебные метаданные _meta, видимые только виджету;
- widgetState — сохранённое UI‑состояние, которое ChatGPT хранит вместе с сообщением.
3. Что именно попадает в виджет: ToolInput, ToolOutput, Metadata, WidgetState
Эти три типа стейта в ChatGPT App как раз отражаются в конкретных полях, которые платформа кладёт в window.openai и прокидывает в хуки SDK. На практике вы будете получать их через React‑хуки, но полезно знать точные определения.
toolInput
Это объект с аргументами инструмента (tool), которые модель передала при его вызове.
Например, пользователь пишет:
«Подбери идеи подарков для женщины 30 лет, бюджет 100 долларов».
Модель решает вызвать ваш инструмент gift_search с аргументами:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
Именно этот объект вы увидите в toolInput внутри виджета. Там хранятся исходные настройки сценария — то, ради чего вообще запускали ваш App.
toolOutput
Это structuredContent, который вернул ваш MCP‑сервер / backend при выполнении инструмента.
Обычно это 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
Это JSON‑объект, в котором ChatGPT хранит снимок UI‑состояния конкретного виджета между рендерами.
Его свойства:
- живёт на стороне 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, но в лекции мы придерживаемся рекомендованного пути — React‑хуков на слое SDK.
Хук 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. Синхронизация UI с toolOutput: загрузка, пустые данные, ошибки
В реальном мире toolOutput не всегда прилетает мгновенно и не всегда «красивый». Документация Apps SDK явно рекомендует думать о трёх состояниях: загрузка, нормальные данные, ошибка/пусто.
Простейший паттерн:
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
↓
Сервер → считает, ходит в БД/интеграции, возвращает structuredContent и _meta
↓
ChatGPT → кладёт structuredContent в toolOutput
↓
Виджет → рендерит UI из toolOutput + widgetState
Официальный гайд по серверу рисует почти такую же диаграмму «User → Model → MCP tool → widget iframe», где toolOutput — главный вход для виджета.
8. Многошаговый сценарий: текущий шаг в widgetState
Наш GiftGenius вряд ли ограничится одной карточкой. Чаще всего хочется «мастер» из шагов: сначала собрать предпочтения, потом определиться с бюджетом, а в конце предложить конкретные варианты.
Логичный способ хранить номер шага мастера — в widgetState. Ровно так рекомендуют делать в документации и примерах по этой теме.
Пример мини‑мастера из двух шагов:
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: … };
- при следующем рендере (в том числе через минуту или при повторном открытии этого сообщения) виджет сразу окажется на шаге 2 с сохранённым бюджетом.
В более продвинутой версии вы на втором шаге уже запускаете инструмент через useCallTool, передаёте туда budget и читаете результат из toolOutput. Но это уже отсылка к модулю про инструменты (Модуль 4), сегодня главное — где мы держим информацию о шаге.
9. Куда что класть: паттерн «тонкий UI, толстый backend»
Обобщим распределение ролей:
- authoritative данные (список подарков, статусы заказов) живут на сервере и приходят в toolOutput;
- временные визуальные штуки (раскрыт ли спойлер, текущее содержимое незаконченного ввода) живут в локальном React‑стейте;
- устойчивые UI‑решения внутри одного виджета (текущий шаг, выбранный элемент, сортировка) живут в widgetState;
- долговременные настройки пользователя между чатами (любимая категория подарков, последняя валюта) живут в вашем бэкенде как persistent state.
Очень хочется иногда сделать «большой объект всего», положить его в widgetState и жить спокойно. Но это плохая идея. Документация подчёркивает, что состояние, которое вы передаёте через widgetState, целиком входит в контекст модели и должно быть лёгким и в основном про UI.
То же касается toolOutput: туда стоит класть ровно те данные, которые нужны и виджету, и модели для объяснения пользователю, что произошло. Большие деревья, бинарные блобы, сырые ответы других API — это всё прямая дорога к странным и дорогим ответам модели.
Insight
Внутри виджета ChatGPT невозможно опираться на классические механизмы клиентской идентификации. Cookies фактически недоступны: виджет загружается как сторонний ресурс в песочнице 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. Если держать эту схему в голове и не пытаться запихнуть «всё и сразу» в один слой, виджет остаётся предсказуемым и для пользователя, и для модели.
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 продолжит показывать старые данные. Лучше читать toolOutput напрямую из useWidgetProps или производить derive‑состояние (мэппинг, фильтр) в рендере, не дублируя весь объект.
Ошибка №5: Использовать только локальный useState там, где нужен widgetState.
Классический баг: вы делаете маленького мастера, храните currentStep в локальном стейте, всё тестируете — работает. Потом пользователь пролистывает чат, возвращается — и вдруг снова первым шагом. Причина проста: локальный стейт не пережил размонтирование виджета. Для шагов, важных в сценарии, стоит завести widgetState, тогда платформа восстановит их вместе с сообщением.
Ошибка №6: Пытаться обращаться к window.openai напрямую в каждом компоненте.
Формально это работает, но вы получаете жёсткую привязку к глобалу, сложный для отладки код и руками написанные подписки на события. Официальные материалы и примеры советуют использовать слой хуков (useWidgetProps, useWidgetState, useOpenAiGlobal), которые инкапсулируют все детали и легче тестируются.
Ошибка №7: Не учитывать message‑scoped природу виджетов.
Если пользователь не нажимает follow‑up, а просто пишет новое сообщение в чат, ChatGPT создаёт новый экземпляр виджета с новым widgetId и пустым widgetState. Сценарии, которые полагаются на «вечную» память одного виджета, начинают вести себя странно. Здесь нужно либо хранить кросс‑сессионный контекст на сервере, либо строить UX вокруг follow‑up’ов и явного продолжения сценария.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ