CodeGym /Cours /ChatGPT Apps /Gestion de l’état — Widget State, ToolInput, ToolOutput

Gestion de l’état — Widget State, ToolInput, ToolOutput

ChatGPT Apps
Niveau 3 , Leçon 2
Disponible

1. Pourquoi réfléchir à l’état du widget

Dans une application React classique, vous avez l’habitude : il y a l’état local, des requêtes d’API, au maximum un Zustand/Redux. Tout tourne autour du navigateur de l’utilisateur.

Dans une ChatGPT App, la situation est différente. Votre widget n’est qu’une fine couche d’UI au‑dessus de trois autres entités :

  • le modèle ChatGPT, qui décide quand appeler votre App et quels arguments lui passer;
  • un serveur MCP / votre backend, qui stocke les vraies données et exécute la logique métier;
  • le contexte de chat, dans lequel tout cela vit et qui peut être rouvert après une heure, un jour ou une semaine.

Par conséquent, « où réside l’état » n’est pas une question académique, mais très pratique. Si vous mettez tout uniquement dans l’état React, au moindre changement du chat l’utilisateur perdra sa sélection. Si vous mettez tout dans le widgetState, le modèle commencera à lire des tonnes de JSON et à halluciner joyeusement à son propos. À l’inverse, si vous essayez de tout garder côté serveur et de tout redemander au pixel près, ce sera lent et coûteux.

Les recommandations officielles divisent explicitement l’état d’une ChatGPT App en trois classes : données métier, état d’UI éphémère et état durable inter‑sessions. Commençons par cela.

2. Carte des états dans une ChatGPT App

La documentation de l’Apps SDK décrit trois types d’état. Il est pratique de les garder en tête sous forme d’un seul tableau :

Type d’état Où il vit Cycle de vie Exemples
Business data (authoritative) Serveur MCP / votre backend Long : des jours, des semaines, des années tâches, commandes, produits
UI state (ephemeral) À l’intérieur du widget concerné Tant que l’instance du widget vit carte sélectionnée, tri, accordéon/volet déplié
Cross‑session state (durable) Votre backend / stockage Entre les sessions et les chats filtres enregistrés, workspace, pinned board

Important : les données de référence (authoritatives) doivent rester sur le serveur, pas dans le widget. Le widget reçoit un instantané de ces données via les outils (MCP tools) et les rend, en leur superposant son état d’UI local.

Dans cette leçon, nous nous concentrons sur ce que voit le widget :

  • toolInput — les arguments d’entrée de l’outil appelé;
  • toolOutput — le structuredContent renvoyé par le serveur (les données principales);
  • toolResponseMetadata — les métadonnées techniques _meta, visibles uniquement par le widget;
  • widgetState — l’état d’UI sauvegardé que ChatGPT stocke avec le message.

3. Ce qui arrive précisément dans le widget : ToolInput, ToolOutput, Metadata, WidgetState

Ces trois types d’état dans une ChatGPT App se reflètent dans des champs concrets que la plateforme place dans window.openai et achemine vers les hooks de l’SDK. En pratique, vous les recevrez via des hooks React, mais il est utile d’en connaître les définitions exactes.

toolInput

C’est l’objet avec les arguments de l’outil (tool) que le modèle a transmis lors de son appel.

Par exemple, l’utilisateur écrit :
« Trouve des idées de cadeaux pour une femme de 30 ans, budget 100 dollars. »
Le modèle décide d’appeler votre outil gift_search avec les arguments :

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

C’est exactement cet objet que vous verrez dans toolInput à l’intérieur du widget. Il contient les paramètres initiaux du scénario — la raison pour laquelle votre App a été lancée.

toolOutput

C’est le structuredContent que votre serveur MCP / backend a renvoyé lors de l’exécution de l’outil.

En général, c’est un JSON du style :

{
  "gifts": [
    { "id": "1", "title": "Guide de l’Islande", "price": 45 },
    { "id": "2", "title": "Livre électronique sur les voyages", "price": 20 }
  ],
  "total": 2
}

toolOutput est la source de données principale pour le rendu. La documentation souligne que le modèle lit ce champ littéralement, donc gardez‑le compact et compréhensible.

toolResponseMetadata

C’est le _meta de la réponse de l’outil, également accessible via window.openai en tant que toolResponseMetadata. La documentation souligne que le contenu de _meta n’est visible que par le widget, pas par le modèle.

Exemples typiques :

  • des ID internes de votre système;
  • des indicateurs pour l’UI (par ex. « y avait‑il du cache »);
  • des messages techniques pour le débogage.

En très bref : toolOutput — « ce qu’il faut dire à l’utilisateur et au modèle », et _meta — « ce qui n’est utile qu’au widget et aux logs ».

widgetState

C’est un objet JSON dans lequel ChatGPT stocke un instantané de l’état d’UI d’un widget donné entre les rendus.

Ses propriétés :

  • il vit côté ChatGPT et est lié à un message/widgetId précis;
  • il est restauré lors de la réouverture de ce même message;
  • il est visible à la fois par le widget et par le modèle (les données de widgetState entrent dans le contexte de la LLM);
  • il est limité en taille à environ 4 k tokens, donc on ne peut pas y mettre « tout et n’importe quoi » ni de longues listes.

Important : widgetState n’est pas l’endroit pour les secrets. N’y placez ni tokens ni données personnelles (PII), car le modèle les verra et la plateforme ne le présente pas comme un stockage sécurisé.

4. État React local : où il reste indispensable

Malgré tout le « magique » autour de toolOutput et de widgetState, à l’intérieur du widget vous écrivez toujours du React ordinaire avec useState, useReducer, useRef, etc. La seule différence :

  • l’état local vit tant que vit le rendu/iframe concret;
  • le modèle ne le voit pas du tout;
  • au démontage du widget (l’utilisateur va sur un autre chat, re‑rendu, mise à jour), l’état local disparaît.

L’état local convient parfaitement pour :

  • les choses instantanées — hover, onglet sélectionné, menu déroulant ouvert;
  • la saisie d’un formulaire jusqu’au clic sur « Continuer »/« Enregistrer »;
  • des indicateurs temporaires comme isSubmitting ou isTooltipOpen.

Mini‑exemple dans notre App de formation GiftGenius — un assistant de sélection de cadeaux :

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

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

Tant que nous n’avons pas cliqué sur « Confirmer le choix », c’est un excellent candidat pour l’état local. Mais dès que nous voulons que la sélection « survive » entre les mises à jour du widget, il faut penser à widgetState.

5. widgetState : la mémoire du widget entre les rendus

widgetState est la « mémoires » du widget que la plateforme sauvegarde. À chaque action UI importante, vous pouvez appeler setWidgetState, et ChatGPT enregistre ce JSON avec le message. Au prochain rendu de ce même widget (par exemple, l’utilisateur remonte l’historique puis revient), le SDK restaurera cet objet et vous le transmettra.

Strictement parlant, on pourrait appeler directement window.openai.widgetState et window.openai.setWidgetState, mais dans cette leçon nous suivons la voie recommandée : des hooks React côté SDK.

Hook useWidgetState

L’un de ces hooks encapsule justement widgetState. Il :

  • prend la valeur initiale soit depuis window.openai.widgetState, soit depuis le defaultState fourni;
  • s’abonne aux mises à jour du host;
  • à chaque setWidgetState de votre part, synchronise la nouvelle valeur vers le haut via window.openai.setWidgetState.

Exemple d’utilisation typique dans un composant de widget (la syntaxe peut légèrement différer selon le template, mais l’idée est là) :

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

type GiftUiState = { likedIds: string[] };

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

Désormais, uiState sera restauré même après que l’utilisateur :

  • a réduit/agrandi le chat;
  • est passé à un autre dialogue puis est revenu;
  • a actualisé la page (si la plateforme a décidé de restaurer ce widget).

Exemple : mémoriser le cadeau choisi

Prenons la liste des cadeaux depuis le toolOutput et mémorisons le cadeau sélectionné dans le widgetState, pour qu’il ne se perde pas.

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

Point important : setUiState ne modifie pas seulement l’état React local, il appelle aussi window.openai.setWidgetState sous le capot, si disponible.

Si l’utilisateur clique plus tard sur un follow‑up sous ce widget, ChatGPT peut poursuivre le dialogue avec le même widgetId et le même widgetState, et le modèle verra quel cadeau a été choisi.

6. Lecture des données de l’outil dans React : useWidgetProps et analogues

Pour éviter que chaque composant aille lire à la main window.openai.toolOutput, l’Apps SDK fournit une autre couche utile : le hook useWidgetProps. Il récupère le toolOutput depuis le global, vous renvoie un objet typé et peut, si besoin, injecter des valeurs par défaut.

La signature simplifiée ressemble à ceci :

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

Autrement dit, il vous retourne simplement toolOutput en tant que type T.

Supposons que notre outil MCP renvoie un structuredContent de ce type :

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

Le widget peut le lire ainsi :

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

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

  if (!gifts.length) {
    return <div>Pas encore d’idées adaptées. Essayez une autre requête.</div>;
  }

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

Plusieurs bonnes pratiques ici :

  • nous ne supposons pas que toolOutput est déjà là — nous définissons une valeur par défaut;
  • nous gérons proprement la liste vide;
  • aucun accès direct à window.openai — tout passe par le hook.

7. Synchroniser l’UI avec toolOutput : chargement, données vides, erreurs

Dans le monde réel, toolOutput n’arrive pas toujours instantanément ni toujours « propre ». La documentation de l’Apps SDK recommande explicitement de penser à trois états : chargement, données normales, erreur/vide.

Patron le plus simple :

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

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

if (data === null) {
  return <div>Chargement des idées de cadeaux…</div>;
}

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

if (!data.gifts.length) {
  return <div>Rien ne correspond à vos critères.</div>;
}

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

Cette approche se marie bien avec le fait que le serveur et le modèle peuvent ré‑appeler l’outil, et que vous recevrez un nouveau toolOutput. Le widget recevra alors simplement la nouvelle valeur via useWidgetProps et se re‑rendra.

Globalement, cela ressemble à ceci :

Utilisateur → requête
      ↓
Modèle → appelle l’outil MCP
      ↓
Serveur → calcule, interroge BD/intégrations, renvoie structuredContent et _meta
      ↓
ChatGPT → met structuredContent dans toolOutput
      ↓
Widget → rend l’UI depuis toolOutput + widgetState

Le guide officiel côté serveur dessine presque le même diagramme « User → Model → MCP tool → widget iframe », où toolOutput est l’entrée principale du widget.

8. Scénario multi‑étapes : l’étape courante dans le widgetState

Notre GiftGenius ne se limitera sans doute pas à une seule carte. Le plus souvent, on veut un « wizard » en étapes : d’abord recueillir les préférences, puis décider du budget, et enfin proposer des options concrètes.

Une façon logique de stocker le numéro d’étape du wizard est dans le widgetState. C’est exactement ce que recommande la documentation et les exemples sur le sujet.

Exemple d’un mini‑wizard en deux étapes :

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>Je cherche des cadeaux jusqu’à {state.budget} $…</div>
    {/* on pourrait déjà rendre toolOutput avec les cadeaux ici */}
  </div>
);

Points intéressants ici :

  • au premier affichage, step vaut 1, l’utilisateur saisit le budget;
  • après onBlur, nous mettons à jour le widgetState à { step: 2, budget:};
  • au rendu suivant (y compris une minute plus tard ou lors d’une réouverture de ce message), le widget se retrouvera directement à l’étape 2 avec le budget sauvegardé.

Dans une version plus avancée, vous lanceriez la deuxième étape via useCallTool, y passeriez le budget et liriez le résultat depuis le toolOutput. Mais cela renvoie au module sur les outils (Module 4) ; aujourd’hui, l’essentiel est l’endroit où nous tenons l’information d’étape.

9. Où mettre quoi : le patron « UI fine, backend épais »

Résumons la répartition des rôles :

  • les données de référence (liste des cadeaux, statuts des commandes) vivent sur le serveur et arrivent via toolOutput;
  • les éléments visuels temporaires (si un accordéon est déplié, le contenu saisit non validé) vivent dans l’état React local;
  • les choix d’UI persistants à l’intérieur d’un même widget (étape courante, élément sélectionné, tri) vivent dans le widgetState;
  • les préférences durables de l’utilisateur entre les chats (catégorie de cadeaux favorite, dernière devise) vivent dans votre backend en tant qu’état persistant.

On est parfois tenté de faire un « gros objet fourre‑tout », de le mettre dans le widgetState et de dormir tranquille. Mauvaise idée. La documentation souligne que l’état que vous passez via widgetState entre entièrement dans le contexte du modèle et doit rester léger et majoritairement orienté UI.

Même chose pour toolOutput : n’y mettez que les données nécessaires au widget et au modèle pour expliquer à l’utilisateur ce qui s’est passé. De grands arbres, des blobs binaires, des réponses brutes d’autres API — tout cela mène à des réponses bizarres et coûteuses du modèle.

Insight

À l’intérieur d’un widget ChatGPT, il est impossible de s’appuyer sur les mécanismes classiques d’identification client. Les cookies sont de facto indisponibles : le widget se charge comme une ressource tierce dans le bac à sable de ChatGPT, et les navigateurs modernes bloquent les cookies tiers par défaut. De ce fait, toute tentative de sauvegarder l’état via un cookie ne fonctionne pas.

Vérifié expérimentalement : localStorage fonctionne très bien, vous pouvez vous y fier lors de la conception de vos applications.

10. Petit exemple de bout en bout : GiftGenius avec sélection persistante

Mettons tout ensemble dans un mini‑widget qui :

  • lit les données depuis toolOutput;
  • sauvegarde la sélection de l’utilisateur dans le widgetState;
  • gère proprement les données vides.
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 instant, nous cherchons des idées…</div>;
  }
  if (data.error) {
    return <div>Erreur: {data.error}</div>;
  }
  if (!data.gifts.length) {
    return <div>Malheureusement, nous n’avons rien trouvé. Essayez une autre requête.</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>
  );
}

Ce code est déjà assez proche d’un widget réel :

  • si l’outil s’exécute encore, on voit « nous cherchons des idées »;
  • si le serveur renvoie une erreur — nous l’affichons honnêtement;
  • s’il n’y a pas de cadeaux — nous gérons correctement le résultat vide;
  • le cadeau choisi est mémorisé dans le widgetState, et le modèle peut l’utiliser dans les étapes suivantes du dialogue.

Ensuite, vous pourrez ajouter des boutons « Continuer avec ce cadeau » (follow‑up), lancer de nouveaux outils, etc., en vous appuyant sur le fait que la sélection est déjà dans l’état.

Au final, une bonne architecture d’état dans une ChatGPT App se résume à une idée simple : les données métier vivent sur le serveur, l’instantané courant arrive via toolOutput, l’UI temporaire — dans le useState local, et le contexte de widget persistant mais lié à un seul message — dans le widgetState. Si vous gardez ce schéma en tête et évitez de tout entasser dans une seule couche, le widget reste prévisible pour l’utilisateur et pour le modèle.

11. Erreurs typiques avec Widget State, ToolInput et ToolOutput

Erreur n° 1 : stocker des données métier dans widgetState au lieu du serveur.
On a parfois envie de conserver toute une liste d’entités dans le widgetState pour éviter de rappeler le serveur. C’est mauvais pour deux raisons : vous dupliquez les données de référence (le serveur et le widget peuvent diverger), et vous gonflez le contexte du modèle, puisque le widgetState y entre en entier. Mieux vaut garder les vraies données sur le serveur et renvoyer un toolOutput frais comme instantané.

Erreur n° 2 : mettre des secrets ou des PII dans widgetState.
Étant donné que le contenu de widgetState est visible par le modèle et que ce n’est pas conçu comme un stockage sécurisé, n’y placez pas de tokens, identifiants, e‑mails, numéros de téléphone ni aucune information confidentielle. De telles choses doivent vivre sur le serveur, et au maximum, le widgetState contient l’ID d’un enregistrement avec lequel vous travaillez via MCP.

Erreur n° 3 : supposer que toolOutput est toujours présent et toujours correct.
Un widget qui va lire sans vérification toolOutput.gifts[0] finira par casser : l’outil peut renvoyer une erreur, un tableau vide ou changer de structure. Il est recommandé de gérer explicitement les états « chargement », « vide », « erreur », puis seulement de rendre normalement.

Erreur n° 4 : copier toolOutput dans l’état local sans nécessité.
La tentation est de faire const [data, setData] = useState(toolOutput) et de ne travailler ensuite qu’avec ce data. Résultat : vous avez une source de vérité dupliquée ; quand un nouveau toolOutput arrive, l’état local ne le sait pas et l’UI continue d’afficher d’anciennes données. Il vaut mieux lire toolOutput directement via useWidgetProps ou produire un état dérivé (mapping, filtre) au rendu, sans dupliquer tout l’objet.

Erreur n° 5 : n’utiliser que le useState local là où il faudrait widgetState.
Le bug classique : vous faites un petit wizard, stockez currentStep dans l’état local, vous testez — ça marche. Puis l’utilisateur fait défiler le chat, revient — et se retrouve au premier écran. Raison simple : l’état local n’a pas survécu au démontage du widget. Pour les étapes importantes du scénario, il faut un widgetState, que la plateforme restaurera avec le message.

Erreur n° 6 : accéder à window.openai directement dans chaque composant.
Formellement, cela fonctionne, mais vous vous liez fortement à un global, obtenez un code difficile à déboguer et des abonnements aux événements écrits à la main. Les ressources et exemples officiels conseillent d’utiliser la couche de hooks (useWidgetProps, useWidgetState, useOpenAiGlobal), qui encapsulent les détails et se testent plus aisément.

Erreur n° 7 : oublier la portée « message‑scoped » des widgets.
Si l’utilisateur ne clique pas un follow‑up et écrit simplement un nouveau message dans le chat, ChatGPT crée une nouvelle instance du widget avec un nouveau widgetId et un widgetState vide. Les scénarios qui comptent sur une « mémoire éternelle » d’un seul widget se comportent alors étrangement. Il faut soit stocker le contexte inter‑sessions côté serveur, soit construire l’UX autour des follow‑ups et de la poursuite explicite du scénario.

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