1. Po co w ogóle myśleć o stanie widżetu
W zwykłej aplikacji React jesteś przyzwyczajony: jest stan lokalny, są zapytania do API, w porywach — jakiś Zustand/Redux. Wszystko kręci się wokół przeglądarki użytkownika.
W ChatGPT App sytuacja jest inna. Twój widżet to tylko cienka warstwa UI nad trzema innymi bytami:
- modelem ChatGPT, która decyduje, kiedy w ogóle wywołać twój App i jakie argumenty mu przekazać;
- serwerem MCP/backendem, który przechowuje prawdziwe dane i wykonuje logikę biznesową;
- kontekstem czatu, w którym to wszystko żyje i może być ponownie otwierane za godzinę, dzień lub tydzień.
Dlatego „gdzie leży stan” to nie pytanie akademickie, tylko bardzo praktyczne. Jeśli wszystko wrzucisz tylko do stanu React, przy najmniejszej zmianie w czacie użytkownik straci wybór. Jeśli wepchniesz wszystko do widgetState, model zacznie czytać tony JSON-u i radośnie halucynować na jego temat. Jeśli z kolei spróbujesz trzymać wszystko na serwerze i odświeżać każdy piksel — będzie wolno i drogo.
Oficjalne zalecenia dzielą stan ChatGPT App na trzy klasy: dane biznesowe, efemeryczny stan UI oraz długotrwały stan między sesjami. Od tego zaczniemy.
2. Mapa stanów w ChatGPT App
Dokumentacja Apps SDK opisuje trzy typy stanu. Wygodnie trzymać je w głowie w postaci jednej tabeli:
| Typ stanu | Gdzie żyje | Cykl życia | Przykłady |
|---|---|---|---|
| Business data (authoritative) | Serwer MCP / twój backend | Długo: dni, tygodnie, lata | zadania, zamówienia, produkty |
| UI state (ephemeral) | Wewnątrz konkretnego widżetu | Tak długo, jak żyje instancja widżetu | wybrana karta, sortowanie, rozwinięty spoiler |
| Cross‑session state (durable) | Twój backend / magazyn | Między sesjami i czatami | zapisane filtry, workspace, pinned board |
Ważne: authoritative dane powinny pozostawać na serwerze, a nie w widżecie. Widżet otrzymuje zrzut tych danych przez narzędzia (MCP tools) i renderuje go, nakładając na niego swój lokalny stan UI.
W tym wykładzie skupiamy się na tym, co widzi sam widżet:
- toolInput — argumenty wejściowe wywołanego narzędzia;
- toolOutput — structuredContent z serwera (główne dane);
- toolResponseMetadata — serwisowe metadane _meta, widoczne tylko dla widżetu;
- widgetState — zapisany stan UI, który ChatGPT przechowuje razem z wiadomością.
3. Co dokładnie trafia do widżetu: ToolInput, ToolOutput, Metadata, WidgetState
Te trzy typy stanu w ChatGPT App odzwierciedlają się w konkretnych polach, które platforma umieszcza w window.openai i przekazuje do hooków SDK. W praktyce będziesz je pobierać przez hooki React, ale warto znać precyzyjne definicje.
toolInput
To obiekt z argumentami narzędzia (tool), które model przekazał przy jego wywołaniu.
Na przykład użytkownik pisze:
„Dobierz pomysły na prezent dla kobiety 30 lat, budżet 100 dolarów.”
Model decyduje się wywołać twoje narzędzie gift_search z argumentami:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
Właśnie ten obiekt zobaczysz w toolInput wewnątrz widżetu. Tam przechowywane są początkowe ustawienia scenariusza — to, po co w ogóle uruchomiono twój App.
toolOutput
To structuredContent, który zwrócił twój serwer MCP / backend podczas wykonywania narzędzia.
Zwykle to JSON w stylu:
{
"gifts": [
{ "id": "1", "title": "Przewodnik po Islandii", "price": 45 },
{ "id": "2", "title": "E-book o podróżach", "price": 20 }
],
"total": 2
}
Właśnie toolOutput jest głównym źródłem danych do renderowania. Oficjalnie podkreślono: model czyta to pole dosłownie, więc trzymaj je zwięzłe i zrozumiałe.
toolResponseMetadata
To _meta z odpowiedzi narzędzia, również dostępne przez window.openai jako toolResponseMetadata. Dokumentacja wyraźnie zaznacza, że zawartość _meta widzi tylko widżet, model jej nie otrzymuje.
Typowe przykłady:
- wewnętrzne ID z twojego systemu;
- flagi dla UI (np. „czy był cache”);
- komunikaty serwisowe do debugowania.
W skrócie: toolOutput — to „co powiedzieć użytkownikowi i modelowi”, a _meta — „co potrzebne tylko widżetowi i logom”.
widgetState
To obiekt JSON, w którym ChatGPT przechowuje zrzut stanu UI konkretnego widżetu między renderami.
Jego właściwości:
- żyje po stronie ChatGPT i jest powiązany z konkretnym message/widgetId;
- odtwarza się przy ponownym otwarciu tej samej wiadomości;
- jest widoczny zarówno dla widżetu, jak i modelu (dane z widgetState trafiają do kontekstu LLM);
- jest ograniczony rozmiarem do około 4k tokenów, więc nie można tam wrzucać wszystkiego ani przechowywać ogromnych list.
Ważne: widgetState to nie miejsce na sekrety. Nie wolno tam umieszczać tokenów ani danych PII, ponieważ model je zobaczy, a sama platforma nie pozycjonuje tego jako bezpiecznego magazynu.
4. Lokalny stan React: gdzie nadal jest potrzebny
Mimo całej magii wokół toolOutput i widgetState, wewnątrz widżetu wciąż piszesz zwykłego Reacta z useState, useReducer, useRef itd. Różnica polega tylko na tym, że:
- stan lokalny żyje tak długo, jak żyje konkretny render/iframe;
- model go w ogóle nie widzi;
- przy odmontowaniu widżetu (użytkownik przeszedł do innego czatu, przerysowanie, odświeżenie) stan lokalny znika.
Stan lokalny świetnie nadaje się do:
- rzeczy natychmiastowych — hover, wybrana karta, otwarty dropdown;
- wprowadzania danych w formularzu do momentu kliknięcia „Kontynuuj”/„Zapisz”;
- tymczasowych flag typu isSubmitting lub isTooltipOpen.
Mini‑przykład w naszym edukacyjnym App GiftGenius — asystencie doboru prezentów:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
Dopóki nie klikniemy „Potwierdź wybór”, to świetny kandydat na stan lokalny. Ale gdy tylko chcemy, aby wybór „przetrwał” między odświeżeniami widżetu, trzeba pomyśleć o widgetState.
5. widgetState: pamięć widżetu między renderami
widgetState to właśnie ta „pamięć” widżetu, którą zapisuje sama platforma. Przy każdej ważnej akcji w UI możesz wywołać setWidgetState, a ChatGPT zapisze ten JSON razem z wiadomością. Przy następnym renderze tego samego widżetu (np. użytkownik przewinął historię czatu wstecz, a potem wrócił) SDK odtworzy ten obiekt i przekaże go tobie.
Ściśle rzecz biorąc, można by bezpośrednio odwoływać się do window.openai.widgetState i window.openai.setWidgetState, ale w wykładzie trzymamy się rekomendowanej ścieżki — hooków React w warstwie SDK.
Hook useWidgetState
Jeden z takich hooków właśnie opakowuje widgetState. On:
- pobiera wartość początkową albo z window.openai.widgetState, albo z przekazanego defaultState;
- subskrybuje aktualizacje od hosta;
- przy każdym twoim setWidgetState synchronizuje nową wartość w górę przez window.openai.setWidgetState.
Przykład typowego użycia wewnątrz komponentu widżetu (składnia może się nieco różnić w szablonie, ale idea jest taka sama):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
Teraz uiState odtworzy się nawet po tym, jak użytkownik:
- zwinął/rozwinął czat;
- przeszedł do innego dialogu i wrócił;
- odświeżył stronę (jeśli platforma zdecydowała się odtworzyć ten widżet).
Przykład: pamiętamy wybrany prezent
Weźmy listę prezentów z toolOutput i zapamiętajmy wybrany prezent w widgetState, żeby się nie gubił.
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>
);
Tu ważny moment: setUiState nie tylko zmienia lokalny stan React, ale też pod spodem wywołuje window.openai.setWidgetState, jeśli jest dostępny.
Jeśli użytkownik później kliknie follow‑up pod tym widżetem, ChatGPT może kontynuować dialog z tym samym widgetId i tym samym widgetState, a model zobaczy, który prezent został wybrany.
6. Odczyt danych narzędzia w React: useWidgetProps i analogi
Żeby każdy komponent nie zaglądał ręcznie do window.openai.toolOutput, w Apps SDK jest jeszcze jedna przydatna warstwa — hook useWidgetProps. Pobiera on toolOutput z globalnego obiektu, daje ci typizowany obiekt i w razie potrzeby domiesza wartości domyślne.
Uproszczona sygnatura wygląda tak:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
Czyli w środku po prostu zwracane jest toolOutput jako typ T.
Załóżmy, że nasz MCP‑tool zwraca taki structuredContent:
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
Widżet może to odczytać tak:
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>Na razie brak odpowiednich pomysłów. Spróbuj innego zapytania.</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
</ul>
);
}
Mamy tu od razu kilka dobrych praktyk:
- nie zakładamy, że toolOutput na pewno już jest — ustawiamy wartość domyślną;
- ostrożnie obsługujemy pustą listę;
- brak bezpośredniego dostępu do window.openai — wszystko przez hook.
7. Synchronizacja UI z toolOutput: ładowanie, puste dane, błędy
W prawdziwym świecie toolOutput nie zawsze dociera natychmiast i nie zawsze jest „ładny”. Dokumentacja Apps SDK wyraźnie rekomenduje myślenie o trzech stanach: ładowanie, dane poprawne, błąd/pusto.
Najprostszy wzorzec:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>Ładujemy pomysły na prezenty…</div>;
}
if (data.error) {
return <div>Błąd: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Nic nie znaleziono dla twoich kryteriów.</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
Takie podejście dobrze współgra z tym, że serwer i model mogą ponownie wywołać narzędzie, a ty otrzymasz nowy toolOutput. Widżet wtedy po prostu dostanie nową wartość przez useWidgetProps i się przerysuje.
W ogólnym przepływie wygląda to tak:
Użytkownik → zapytanie
↓
Model → wywołuje MCP tool
↓
Serwer → liczy, chodzi do bazy/integracji, zwraca structuredContent i _meta
↓
ChatGPT → umieszcza structuredContent w toolOutput
↓
Widżet → renderuje UI z toolOutput + widgetState
Oficjalny przewodnik po serwerze rysuje niemal identyczny diagram „User → Model → MCP tool → widget iframe”, gdzie toolOutput jest głównym wejściem dla widżetu.
8. Scenariusz wieloetapowy: bieżący krok w widgetState
Nasz GiftGenius raczej nie skończy się na jednej karcie. Najczęściej chcemy „kreator” z krokami: najpierw zebrać preferencje, potem ustalić budżet, a na końcu zaproponować konkretne opcje.
Logiczne miejsce na przechowywanie numeru kroku kreatora to widgetState. Dokładnie tak zaleca się w dokumentacji i przykładach.
Przykład mini‑kreatora z dwoma krokami:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
Budżet, $
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>Szukam prezentów do {state.budget} $…</div>
{/* tu moglibyśmy już renderować toolOutput z prezentami */}
</div>
);
Ciekawe punkty:
- przy pierwszym wyświetleniu step równa się 1, użytkownik wpisuje budżet;
- po onBlur aktualizujemy widgetState na { step: 2, budget: … };
- przy następnym renderze (w tym po minucie albo przy ponownym otwarciu tej wiadomości) widżet od razu znajdzie się na kroku 2 z zapisanym budżetem.
W wersji bardziej zaawansowanej na drugim kroku uruchamiasz już narzędzie przez useCallTool, przekazujesz tam budget i czytasz wynik z toolOutput. Ale to już nawiązanie do modułu o narzędziach (Moduł 4), dziś najważniejsze — gdzie trzymamy informację o kroku.
9. Co gdzie odkładać: wzorzec „cienki UI, gruby backend”
Podsumujmy podział ról:
- authoritative dane (lista prezentów, statusy zamówień) żyją na serwerze i przychodzą w toolOutput;
- tymczasowe wizualia (czy spoiler jest rozwinięty, bieżąca treść niedokończonego wpisu) żyją w lokalnym stanie React;
- trwałe decyzje UI w obrębie jednego widżetu (bieżący krok, wybrany element, sortowanie) żyją w widgetState;
- długotrwałe ustawienia użytkownika między czatami (ulubiona kategoria prezentów, ostatnia waluta) żyją w twoim backendzie jako persistent state.
Czasem kusi, by zrobić „duży obiekt wszystkiego”, włożyć go do widgetState i mieć spokój. Ale to zły pomysł. Dokumentacja podkreśla, że stan przekazywany przez widgetState w całości trafia do kontekstu modelu i powinien być lekki oraz głównie dotyczyć UI.
To samo dotyczy toolOutput: warto tam umieszczać dokładnie te dane, które są potrzebne i widżetowi, i modelowi do wyjaśnienia użytkownikowi, co zaszło. Duże drzewa, binarne bloby, surowe odpowiedzi innych API — to prosta droga do dziwnych i kosztownych odpowiedzi modelu.
Insight
Wewnątrz widżetu ChatGPT nie da się polegać na klasycznych mechanizmach identyfikacji klienta. Cookies są w praktyce niedostępne: widżet ładuje się jako zasób zewnętrzny w piaskownicy ChatGPT, a nowoczesne przeglądarki domyślnie blokują third‑party cookies. Przez to wszelkie próby zapisu stanu przez cookie nie działają.
Sprawdzone eksperymentalnie: localStorage działa bardzo dobrze, możesz na nim polegać przy projektowaniu swoich aplikacji.
10. Mały przykład end‑to‑end: GiftGenius z trwałym wyborem
Złóżmy wszystko w mini‑widżet, który:
- czyta dane z toolOutput;
- zapisuje wybór użytkownika w widgetState;
- ostrożnie obsługuje puste dane.
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>Chwila, dobieramy pomysły…</div>;
}
if (data.error) {
return <div>Błąd: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Niestety nic nie znaleźliśmy. Spróbuj innego zapytania.</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>
);
}
Ten kod jest już całkiem bliski realnemu widżetowi:
- jeśli narzędzie wciąż się wykonuje, widzimy „dobieramy pomysły”;
- jeśli serwer zwrócił błąd — uczciwie go pokazujemy;
- jeśli nie ma prezentów — poprawnie obsługujemy pusty wynik;
- wybrany prezent zapamiętuje się w widgetState, a model może użyć go w kolejnych krokach dialogu.
Dalej możesz dodać przyciski „Kontynuuj z tym prezentem” (follow‑up), uruchamianie nowych narzędzi itd., opierając się na tym, że wybór już siedzi w stanie.
W efekcie dobra architektura stanu w ChatGPT App sprowadza się do prostej idei: dane biznesowe żyją na serwerze, bieżący zrzut przychodzi przez toolOutput, tymczasowy UI — w lokalnym useState, a trwały, lecz powiązany z jedną wiadomością kontekst widżetu — w widgetState. Jeśli trzymać się tego schematu i nie próbować upychać „wszystkiego naraz” w jednej warstwie, widżet pozostaje przewidywalny zarówno dla użytkownika, jak i dla modelu.
11. Typowe błędy przy pracy z Widget State, ToolInput i ToolOutput
Błąd nr 1: Trzymanie danych biznesowych w widgetState zamiast na serwerze.
Czasem kusi, by zapisać całą listę encji w widgetState, żeby nie wywoływać serwera ponownie. To złe z dwóch powodów: duplikujesz authoritative dane (serwer i widżet mogą się rozjechać) i puchnie kontekst modelu, bo widgetState trafia do niego w całości. Lepiej trzymać prawdziwe dane na serwerze i zwracać świeży toolOutput jako zrzut.
Błąd nr 2: Wpychanie do widgetState sekretów lub PII.
Ponieważ zawartość widgetState widzi model i nie jest to przeznaczone jako bezpieczny magazyn, nie wolno tam umieszczać tokenów, loginów, e‑maili, telefonów ani innych poufnych informacji. Takie rzeczy muszą żyć na serwerze, a w widgetState co najwyżej przechowujesz ID rekordu, z którym dalej pracujesz przez MCP.
Błąd nr 3: Zakładanie, że toolOutput zawsze istnieje i zawsze jest poprawny.
Widżet, który bez sprawdzenia sięga do toolOutput.gifts[0], prędzej czy później się wyłoży: narzędzie może zwrócić błąd, pustą tablicę albo zmienić strukturę. Zaleca się jawnie obsługiwać stany „ładowanie”, „pusto”, „błąd”, a dopiero potem — normalny render.
Błąd nr 4: Kopiowanie toolOutput do lokalnego stanu bez potrzeby.
Kusi, by zrobić const [data, setData] = useState(toolOutput) i dalej żyć tylko z tym data. W rezultacie otrzymujesz zdublowane źródło prawdy: gdy przyjdzie nowy toolOutput, lokalny stan się o tym nie dowie i UI będzie pokazywać stare dane. Lepiej czytać toolOutput bezpośrednio z useWidgetProps albo wyprowadzać stan pochodny (mapowanie, filtr) w renderze, nie duplikując całego obiektu.
Błąd nr 5: Używanie tylko lokalnego useState tam, gdzie potrzebny jest widgetState.
Klasyczny bug: robisz małego kreatora, trzymasz currentStep w lokalnym stanie, wszystko testujesz — działa. Potem użytkownik przewija czat, wraca — i nagle znowu pierwszy krok. Powód jest prosty: lokalny stan nie przetrwał odmontowania widżetu. Dla kroków istotnych w scenariuszu warto użyć widgetState, wtedy platforma odtworzy je razem z wiadomością.
Błąd nr 6: Próby odwoływania się do window.openai bezpośrednio w każdym komponencie.
Formalnie to działa, ale otrzymujesz twarde powiązanie z globalem, trudny do debugowania kod i ręcznie pisane subskrypcje zdarzeń. Oficjalne materiały i przykłady zalecają używanie warstwy hooków (useWidgetProps, useWidgetState, useOpenAiGlobal), które enkapsulują szczegóły i łatwiej je testować.
Błąd nr 7: Nieuwzględnianie charakteru widżetów powiązanych z wiadomością (message‑scoped).
Jeśli użytkownik nie klika follow‑up, tylko po prostu pisze nową wiadomość na czacie, ChatGPT tworzy nową instancję widżetu z nowym widgetId i pustym widgetState. Scenariusze, które polegają na „wiecznej” pamięci jednego widżetu, zaczynają się zachowywać dziwnie. Trzeba wtedy albo trzymać kontekst między sesjami na serwerze, albo budować UX wokół follow‑upów i jawnego kontynuowania scenariusza.
GO TO FULL VERSION