CodeGym /Corsi /ChatGPT Apps /Gestione dello stato — Widget State, ToolInput, ToolOutpu...

Gestione dello stato — Widget State, ToolInput, ToolOutput

ChatGPT Apps
Livello 3 , Lezione 2
Disponibile

1. Perché pensare allo stato del widget

In una normale applicazione React siete abituati: c’è lo stato locale, ci sono le chiamate API, al massimo — qualche Zustand/Redux. Tutto ruota attorno al browser dell’utente.

Nelle ChatGPT App la situazione è diversa. Il vostro widget è solo un sottile livello di UI sopra tre altre entità:

  • il modello di ChatGPT, che decide quando chiamare la vostra App e quali argomenti passarle;
  • l’MCP‑server/backend, che conserva i dati reali ed esegue la logica di business;
  • il contesto della chat, in cui tutto ciò vive e può essere riaperto dopo un’ora, un giorno o una settimana.

Perciò «dove risiede lo stato» non è una questione accademica, ma molto pratica. Se mettete tutto solo nello stato di React, al minimo cambiamento della chat l’utente perderà le scelte. Se invece infilate tutto in widgetState, il modello inizierà a leggere tonnellate di JSON e a galoppare con le allucinazioni. Se, al contrario, cercate di tenere tutto sul server e ricaricare ogni singolo pixel — sarà lento e costoso.

Le linee guida ufficiali dividono chiaramente lo stato di una ChatGPT App in tre classi: dati di business, stato UI effimero e stato durevole tra le sessioni. Da qui partiamo.

2. Mappa degli stati in una ChatGPT App

La documentazione dell’Apps SDK descrive tre tipi di stato. È comodo tenerli a mente sotto forma di una tabella:

Tipo di stato Dove vive Ciclo di vita Esempi
Dati di business (authoritative) MCP‑server / vostro backend Lungo: giorni, settimane, anni attività, ordini, prodotti
Stato UI (effimero) All’interno del widget specifico Finché vive l’istanza del widget card selezionata, ordinamento, pannello espanso
Stato cross‑sessione (durevole) Il vostro backend / archivio Tra sessioni e chat filtri salvati, workspace, bacheca fissata

Importante: i dati autorevoli devono rimanere sul server, non nel widget. Il widget riceve un’istantanea di tali dati tramite gli strumenti (MCP tools) e la rende, sovrapponendovi il proprio stato UI locale.

In questa lezione ci concentriamo su ciò che vede il widget:

  • toolInput — gli argomenti di input dello strumento invocato;
  • toolOutputstructuredContent dal server (i dati principali);
  • toolResponseMetadata — metadati di servizio _meta, visibili solo al widget;
  • widgetState — lo stato UI salvato che ChatGPT conserva insieme al messaggio.

3. Cosa arriva esattamente al widget: ToolInput, ToolOutput, Metadata, WidgetState

Questi tre tipi di stato in una ChatGPT App si riflettono nei campi specifici che la piattaforma mette in window.openai e propaga agli hook dell’SDK. In pratica li otterrete tramite gli hook di React, ma è utile conoscerne le definizioni precise.

toolInput

È un oggetto con gli argomenti dello strumento (tool) che il modello ha passato quando l’ha invocato.

Per esempio, l’utente scrive:
«Trova idee regalo per una donna di 30 anni, budget 100 dollari».
Il modello decide di chiamare il vostro strumento gift_search con gli argomenti:

{
  "recipient": "female",
  "age": 30,
  "budget": 100,
  "occasion": "birthday"
}

Questo è esattamente l’oggetto che vedrete in toolInput all’interno del widget. Qui sono memorizzate le impostazioni di origine dello scenario — il motivo per cui la vostra App è stata avviata.

toolOutput

È lo structuredContent che ha restituito il vostro MCP‑server / backend durante l’esecuzione dello strumento.

Di solito è un JSON del tipo:

{
  "gifts": [
    { "id": "1", "title": "Guida dell'Islanda", "price": 45 },
    { "id": "2", "title": "Libro elettronico sui viaggi", "price": 20 }
  ],
  "total": 2
}

Proprio toolOutput è la fonte principale di dati per il rendering. Ufficialmente si sottolinea: il modello legge questo campo alla lettera, quindi mantenetelo compatto e chiaro.

toolResponseMetadata

È lo _meta della risposta dello strumento, anch’esso accessibile tramite window.openai come toolResponseMetadata. La documentazione sottolinea che il contenuto di _meta è visibile solo al widget: il modello non lo riceve.

Esempi tipici:

  • ID interni del vostro sistema;
  • flag per l’UI (per esempio, «era presente la cache?»);
  • messaggi di servizio per il debugging.

In breve: toolOutput è «cosa dire all’utente e al modello», mentre _meta è «ciò che serve solo al widget e ai log».

widgetState

È un oggetto JSON in cui ChatGPT memorizza un’istantanea dello stato UI del widget tra un render e l’altro.

Le sue proprietà:

  • vive lato ChatGPT ed è legato a uno specifico message/widgetId;
  • viene ripristinato quando si riapre lo stesso messaggio;
  • è visibile sia al widget sia al modello (i dati di widgetState entrano nel contesto LLM);
  • è limitato in dimensione a circa 4k token, per cui non potete metterci dentro di tutto né conservare liste enormi.

Importante: widgetState non è il posto per i segreti. Non inserite token né dati PII, perché il modello li vedrà e la piattaforma non lo considera un archivio sicuro.

4. Stato locale di React: dove serve ancora

Nonostante tutta la magia attorno a toolOutput e widgetState, dentro il widget scrivete comunque il normale React con useState, useReducer, useRef ecc. La differenza è solo che:

  • lo stato locale vive finché vive quello specifico render/iframe;
  • il modello non lo vede affatto;
  • allo smontaggio del widget (utente passa a un’altra chat, rerender, aggiornamento) lo stato locale scompare.

Lo stato locale è perfetto per:

  • cose istantanee — hover, tab selezionata, dropdown aperto;
  • l’input di un form prima di premere «Continua»/«Salva»;
  • flag temporanei come isSubmitting o isTooltipOpen.

Mini‑esempio nella nostra app didattica GiftGenius — l’assistente per la scelta dei regali:

const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);

return (
  <div>
    {gifts.map(gift => (
      <button
        key={gift.id}
        onClick={() => setSelectedGiftId(gift.id)}
      >
        {gift.title}
      </button>
    ))}
  </div>
);

Finché non premiamo «Conferma la scelta», questo è un ottimo candidato per lo stato locale. Ma non appena vogliamo che la scelta «sopravviva» tra i refresh del widget, bisogna pensare a widgetState.

5. widgetState: la memoria del widget tra i render

widgetState è proprio quella «memoria» del widget che la piattaforma salva. A ogni azione importante della UI potete chiamare setWidgetState, e ChatGPT salverà questo JSON insieme al messaggio. Al successivo render dello stesso widget (ad esempio l’utente scorre la cronologia della chat e poi torna) l’SDK ripristinerà l’oggetto e ve lo passerà.

Stricto sensu si potrebbe toccare direttamente window.openai.widgetState e window.openai.setWidgetState, ma in questa lezione seguiamo il percorso consigliato — gli hook di React allo strato SDK.

Hook useWidgetState

Uno di questi hook incapsula proprio widgetState. Esso:

  • prende il valore iniziale da window.openai.widgetState oppure da defaultState passato;
  • si sottoscrive agli aggiornamenti dall’host;
  • a ogni vostro setWidgetState sincronizza il nuovo valore verso l’alto tramite window.openai.setWidgetState.

Esempio di utilizzo tipico all’interno del componente del widget (la sintassi può variare leggermente nello scaffold, ma l’idea è questa):

import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";

type GiftUiState = { likedIds: string[] };

const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
  likedIds: [],
}));

Ora uiState verrà ripristinato anche dopo che l’utente:

  • ha ridotto/espanso la chat;
  • è passato a un altro dialogo ed è tornato;
  • ha ricaricato la pagina (se la piattaforma decide di ripristinare quel widget).

Esempio: ricordare il regalo selezionato

Prendiamo l’elenco dei regali da toolOutput e memorizziamo il regalo selezionato in widgetState così da non perderlo.

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>
);

Qui un punto importante: setUiState non cambia solo lo stato locale di React, ma chiama anche window.openai.setWidgetState sotto il cofano, se disponibile.

Se in seguito l’utente preme un follow‑up sotto questo widget, ChatGPT può proseguire la conversazione con lo stesso widgetId e lo stesso widgetState, e il modello vedrà quale regalo è stato selezionato.

6. Lettura dei dati dello strumento in React: useWidgetProps e analoghi

Per evitare che ogni componente acceda manualmente a window.openai.toolOutput, nell’Apps SDK c’è un altro utile livello — l’hook useWidgetProps. Esso preleva toolOutput dal globale, vi fornisce un oggetto tipizzato e, se volete, mescola valori di default.

La firma semplificata è così:

export function useWidgetProps<T>(defaultState?: T | () => T): T {
  const toolOutput = useOpenAIGlobal("toolOutput") as T;
  return toolOutput ?? defaultState ?? null;
}

In altre parole, all’interno vi viene semplicemente restituito toolOutput come tipo T.

Supponiamo che il nostro strumento MCP restituisca questo structuredContent:

type GiftToolOutput = {
  gifts: { id: string; title: string; price: number }[];
  currency: string;
};

Il widget può leggerlo così:

import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";

export function GiftListWidget() {
  const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
    gifts: [],
    currency: "USD",
  }));

  if (!gifts.length) {
    return <div>Al momento non ci sono idee adatte. Prova un'altra richiesta.</div>;
  }

  return (
    <ul>
      {gifts.map(gift => (
        <li key={gift.id}>
          {gift.title} — {gift.price} {currency}
        </li>
      ))}
    </ul>
  );
}

Qui ci sono subito diverse buone pratiche:

  • non diamo per scontato che toolOutput ci sia già — impostiamo un valore di default;
  • gestiamo con cura l’elenco vuoto;
  • nessun accesso diretto a window.openai — tutto tramite l’hook.

7. Sincronizzare l’UI con toolOutput: caricamento, dati vuoti, errori

Nel mondo reale toolOutput non arriva sempre all’istante e non è sempre «bello». La documentazione dell’Apps SDK raccomanda esplicitamente di pensare a tre stati: caricamento, dati normali, errore/vuoto.

Pattern più semplice:

type GiftToolOutput = {
  gifts: { id: string; title: string }[];
  error?: string;
};

const data = useWidgetProps<GiftToolOutput | null>(() => null);

if (data === null) {
  return <div>Carichiamo idee regalo…</div>;
}

if (data.error) {
  return <div>Errore: {data.error}</div>;
}

if (!data.gifts.length) {
  return <div>Non è stato trovato nulla per i tuoi criteri.</div>;
}

return (
  <ul>
    {data.gifts.map(gift => (
      <li key={gift.id}>{gift.title}</li>
    ))}
  </ul>
);

Questo approccio si combina bene con il fatto che il server e il modello possono richiamare lo strumento, e riceverete un nuovo toolOutput. Il widget allora otterrà semplicemente il nuovo valore tramite useWidgetProps e verrà rirenderizzato.

Nel flusso generale appare così:

Utente → richiesta
      ↓
Modello → invoca l'MCP tool
      ↓
Server → elabora, va su DB/integrations, restituisce structuredContent e _meta
      ↓
ChatGPT → mette lo structuredContent in toolOutput
      ↓
Widget → rende l'UI da toolOutput + widgetState

La guida ufficiale lato server disegna quasi lo stesso diagramma «User → Model → MCP tool → widget iframe», dove toolOutput è l’ingresso principale per il widget.

8. Scenario a più passi: passo corrente in widgetState

Il nostro GiftGenius difficilmente si limiterà a una sola card. Spesso si vuole una «procedura guidata» a passi: prima raccogliere preferenze, poi definire il budget e infine proporre opzioni concrete.

Un modo logico per memorizzare il numero del passo della procedura guidata è in widgetState. È esattamente ciò che si consiglia nella documentazione e negli esempi.

Esempio di mini‑procedura guidata in due passi:

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>Cerco regali fino a {state.budget} $…</div>
    {/* qui si potrebbe già effettuare il rendering di toolOutput con i regali */}
  </div>
);

Qui ci sono aspetti interessanti:

  • alla prima visualizzazione step è 1, l’utente inserisce il budget;
  • dopo onBlur aggiorniamo widgetState a { step: 2, budget:};
  • al successivo render (anche dopo un minuto o alla riapertura di questo messaggio) il widget sarà subito al passo 2 con il budget salvato.

In una versione più avanzata, al secondo passo lanciate già lo strumento tramite useCallTool, passate budget e leggete il risultato da toolOutput. Ma questo rimanda al modulo sugli strumenti (Modulo 4), oggi l’importante è dove teniamo l’informazione sul passo.

9. Cosa mettere dove: pattern «UI sottile, backend spesso»

Riepiloghiamo la ripartizione dei ruoli:

  • i dati autorevoli (elenco dei regali, stati degli ordini) vivono sul server e arrivano in toolOutput;
  • gli elementi visivi temporanei (se uno spoiler è aperto, contenuto corrente di un input incompleto) vivono nello stato locale di React;
  • le scelte UI durature all’interno di un widget (passo corrente, elemento selezionato, ordinamento) vivono in widgetState;
  • le impostazioni durature dell’utente tra le chat (categoria di regali preferita, ultima valuta) vivono nel vostro backend come stato persistente.

A volte è forte la tentazione di creare un «grande oggetto di tutto», metterlo in widgetState e vivere sereni. Ma è una cattiva idea. La documentazione sottolinea che lo stato che passate tramite widgetState entra interamente nel contesto del modello e deve essere leggero e principalmente incentrato sull’UI.

Lo stesso vale per toolOutput: lì dovreste mettere esattamente i dati necessari al widget e al modello per spiegare all’utente cosa è successo. Alberi enormi, blob binari, risposte grezze di altre API — tutto ciò porta direttamente a risposte del modello strane e costose.

Insight

All’interno di un widget ChatGPT è impossibile fare affidamento sui meccanismi classici di identificazione lato client. I cookie sono di fatto non disponibili: il widget viene caricato come risorsa di terze parti nella sandbox di ChatGPT e i browser moderni bloccano i cookie di terze parti per impostazione predefinita. Per questo, qualunque tentativo di salvare lo stato tramite cookie non funziona.

Verificato sperimentalmente: localStorage funziona benissimo, potete farci affidamento nella progettazione delle vostre applicazioni.

10. Un piccolo esempio end‑to‑end: GiftGenius con scelta persistente

Mettiamo insieme tutto in un mini‑widget che:

  • legge i dati da toolOutput;
  • salva la scelta dell’utente in widgetState;
  • gestisce con cura i dati vuoti.
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>Un secondo, stiamo cercando idee…</div>;
  }
  if (data.error) {
    return <div>Errore: {data.error}</div>;
  }
  if (!data.gifts.length) {
    return <div>Purtroppo non abbiamo trovato nulla. Prova un'altra richiesta.</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>
  );
}

Questo codice è già piuttosto vicino a un widget reale:

  • se lo strumento è ancora in esecuzione, vediamo «stiamo cercando idee»;
  • se il server ha restituito un errore — lo mostriamo chiaramente;
  • se non ci sono regali — gestiamo correttamente il risultato vuoto;
  • il regalo selezionato viene ricordato in widgetState, e il modello può usarlo nei passi successivi del dialogo.

Poi potrete aggiungere i pulsanti «Continua con questo regalo» (follow‑up), l’avvio di nuovi strumenti e così via, contando sul fatto che la scelta è già nello stato.

In definitiva, una buona architettura dello stato in una ChatGPT App si riduce a un’idea semplice: i dati di business vivono sul server, lo snapshot corrente arriva tramite toolOutput, l’UI temporanea sta nel useState locale, e il contesto del widget durevole ma legato a un singolo messaggio sta in widgetState. Se tenete a mente questo schema e non cercate di infilare «tutto e subito» in un solo livello, il widget rimane prevedibile sia per l’utente sia per il modello.

11. Errori tipici con Widget State, ToolInput e ToolOutput

Errore n. 1: Conservare i dati di business in widgetState invece che sul server.
A volte si vuole salvare un intero elenco di entità in widgetState per evitare di chiamare di nuovo il server. È sbagliato per due motivi: duplicate i dati autorevoli (server e widget possono divergere) e gonfiate il contesto del modello, perché widgetState vi entra per intero. Meglio tenere i dati reali sul server e restituire un toolOutput fresco come istantanea.

Errore n. 2: Mettere in widgetState segreti o PII.
Poiché il contenuto di widgetState è visibile al modello e non è pensato come archivio protetto, non dovete inserirci token, login, email, numeri di telefono e altre informazioni confidenziali. Queste cose devono vivere sul server, mentre in widgetState al massimo si conserva l’ID della record con cui poi lavorate tramite MCP.

Errore n. 3: Dare per scontato che toolOutput esista sempre ed è sempre corretto.
Un widget che senza controlli accede a toolOutput.gifts[0] prima o poi si romperà: lo strumento può restituire un errore, un array vuoto o cambiare struttura. Si consiglia di gestire esplicitamente gli stati «caricamento», «vuoto», «errore» e solo dopo fare il render normale.

Errore n. 4: Copiare toolOutput nello stato locale senza necessità.
È forte la tentazione di fare const [data, setData] = useState(toolOutput) e poi lavorare solo con questo data. Così però ottenete una fonte di verità duplicata: quando arriverà un nuovo toolOutput, lo stato locale non se ne accorgerà e l’UI continuerà a mostrare dati vecchi. Meglio leggere toolOutput direttamente da useWidgetProps o derivare stato (mapping, filtro) nel render, senza duplicare l’intero oggetto.

Errore n. 5: Usare solo il useState locale dove serve widgetState.
Bug classico: implementate una piccola procedura guidata, memorizzate currentStep nello stato locale, tutto sembra funzionare. Poi l’utente scorre la chat, torna — e si ritrova di nuovo al primo passo. La ragione è semplice: lo stato locale non sopravvive allo smontaggio del widget. Per i passi importanti nello scenario conviene usare widgetState, così la piattaforma li ripristina insieme al messaggio.

Errore n. 6: Accedere a window.openai direttamente in ogni componente.
Formalmente funziona, ma ottenete un forte accoppiamento col globale, codice difficile da debuggare e sottoscrizioni agli eventi scritte a mano. Materiali ufficiali ed esempi consigliano di usare lo strato di hook (useWidgetProps, useWidgetState, useOpenAiGlobal), che incapsulano i dettagli e sono più facili da testare.

Errore n. 7: Non considerare la natura message‑scoped dei widget.
Se l’utente non preme un follow‑up, ma scrive semplicemente un nuovo messaggio nella chat, ChatGPT crea una nuova istanza del widget con un nuovo widgetId e un widgetState vuoto. Gli scenari che si affidano alla «memoria eterna» di un widget iniziano a comportarsi in modo imprevisto. Qui bisogna o conservare il contesto cross‑sessione sul server, oppure costruire l’UX attorno ai follow‑up e alla continuazione esplicita dello scenario.

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