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‑сервер / ваш бекенд | Довго: дні, тижні, роки | задачі, замовлення, товари |
| UI state (ephemeral) | Усередині конкретного віджета | Доки живе екземпляр віджета | обрана картка, сортування, розкритий спойлер |
| Cross‑session state (durable) | Ваш бекенд / сховище | Між сесіями та чатами | збережені фільтри, 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‑сервер / бекенд під час виконання інструмента.
Зазвичай це 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);
- обмежений за розміром приблизно 4 тис. токенів, тож туди не можна скидати все підряд або зберігати величезні списки.
Важливо: widgetState — не місце для секретів. Ні токени, ні PII‑дані туди класти не можна, тому що модель їх побачить, а сама платформа не позиціонує це як безпечне сховище.
4. Локальний React‑стан: де він усе ще потрібен
Попри всю «магію» навколо toolOutput і widgetState, усередині віджета ви все ще пишете звичайний React з useState, useReducer, useRef тощо. Різниця лише в тому, що:
- локальний стан живе стільки, скільки живе конкретний рендер/iframe;
- модель його не бачить узагалі;
- під час демонтування віджета (користувач перейшов до іншого чату, повторний рендер, оновлення) локальний стан зникає.
Локальний стан чудово підходить для:
- миттєвих речей — hover, обраної вкладки, відкритого дропдауна;
- введення у формі до натискання «Продовжити»/«Зберегти»;
- тимчасових прапорців на кшталт isSubmitting або isTooltipOpen.
Міні‑приклад усередині нашого навчального застосунку 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 — це пряма дорога до дивних і дорогих відповідей моделі.
Нотатка
Усередині віджета ChatGPT неможливо спиратися на класичні механізми клієнтської ідентифікації. Файли cookie фактично недоступні: віджет завантажується як сторонній ресурс у пісочниці ChatGPT, а сучасні браузери блокують сторонні файли cookie за замовчуванням. Через це будь‑які спроби зберегти стан через 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 або отримувати похідний стан (мапінг, фільтр) у рендері, не дублюючи весь об’єкт.
Помилка №5: Використовувати лише локальний useState там, де потрібен widgetState.
Класичний баг: ви робите маленького майстра, зберігаєте currentStep у локальному стані, усе тестуєте — працює. Потім користувач прокручує чат, повертається — і раптом знову перший крок. Причина проста: локальний стан не пережив демонтування віджета. Для кроків, важливих у сценарії, варто завести widgetState, тоді платформа відновить їх разом із повідомленням.
Помилка №6: Намагатися звертатися до window.openai напряму в кожному компоненті.
Формально це працює, але ви отримуєте жорстку прив’язку до глобальної змінної, складний для налагодження код і вручну написані підписки на події. Офіційні матеріали та приклади радять використовувати шар хуків (useWidgetProps, useWidgetState, useOpenAiGlobal), які інкапсулюють усі деталі та легше тестуються.
Помилка №7: Не враховувати message‑scoped природу віджетів.
Якщо користувач не натискає follow‑up, а просто пише нове повідомлення в чат, ChatGPT створює новий екземпляр віджета з новим widgetId і порожнім widgetState. Сценарії, які покладаються на «вічну» пам’ять одного віджета, починають поводитися дивно. Тут потрібно або зберігати міжсесійний контекст на сервері, або будувати UX навколо follow‑upʼів та явного продовження сценарію.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ