CodeGym /Kursy /ChatGPT Apps /Zarządzanie stanem — Widget State, ToolInput, ToolOutput

Zarządzanie stanem — Widget State, ToolInput, ToolOutput

ChatGPT Apps
Poziom 3 , Lekcja 2
Dostępny

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;
  • toolOutputstructuredContent 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.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION