Skip to main content

React & Apps SDK UI

You've built TaskManager with vanilla JavaScript. It works, but as widgets grow complex, managing DOM updates becomes tedious. React solves this—and OpenAI provides an official component library that matches ChatGPT's design system.

In this lesson, you'll learn the React approach: setting up a project with @openai/apps-sdk-ui, using React hooks for widget state, and building polished UIs with pre-built components. This is how production ChatGPT Apps are built.

Why React for ChatGPT Apps?

Vanilla JavaScript works for simple widgets. But consider what happens as complexity grows:

Vanilla JS ChallengeReact Solution
Manual DOM updatesDeclarative rendering
State scattered across variablesCentralized state with hooks
Event listener managementAutomatic cleanup
Inconsistent stylingDesign system components

The official @openai/apps-sdk-ui library provides:

  • Design tokens matching ChatGPT's colors, typography, spacing
  • Pre-built components: Button, Badge, TextLink, Icons
  • Tailwind 4 integration with zero configuration
  • Accessible components built on Radix primitives

Project Setup

Create a React widget project alongside your MCP server:

taskmanager/
├── server/
│ └── main.py # Your FastMCP server
└── web/
├── package.json
├── tsconfig.json
├── src/
│ ├── main.tsx # Entry point
│ ├── main.css # Tailwind + apps-sdk-ui styles
│ ├── TaskWidget.tsx
│ └── hooks/
│ ├── useOpenAiGlobal.ts
│ └── useWidgetState.ts
└── dist/
└── widget.js # Bundled output

Initialize the Project

cd taskmanager
mkdir -p web/src/hooks
cd web
npm init -y
npm install react react-dom @openai/apps-sdk-ui
npm install -D typescript esbuild @types/react @types/react-dom tailwindcss

Configure TypeScript

Create tsconfig.json:

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"outDir": "./dist"
},
"include": ["src/**/*"]
}

Configure Styles

Create src/main.css:

@import "tailwindcss";
@import "@openai/apps-sdk-ui/css";
@source "../node_modules/@openai/apps-sdk-ui";

/* Your custom styles here */
.task-list {
@apply space-y-2;
}

The useOpenAiGlobal Hook

This hook subscribes to window.openai values reactively. When ChatGPT updates globals, your components re-render automatically.

Create src/hooks/useOpenAiGlobal.ts:

import { useSyncExternalStore } from "react";

// Type definitions for window.openai
declare global {
interface Window {
openai?: {
toolInput?: Record<string, unknown>;
toolOutput?: Record<string, unknown>;
toolResponseMetadata?: Record<string, unknown>;
widgetState?: Record<string, unknown>;
theme?: "light" | "dark";
displayMode?: "inline" | "pip" | "fullscreen";
locale?: string;
setWidgetState?: (state: Record<string, unknown>) => void;
callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown>;
sendFollowUpMessage?: (options: { prompt: string }) => void;
requestDisplayMode?: (options: { mode: string }) => Promise<void>;
};
}
}

type OpenAiGlobals = NonNullable<Window["openai"]>;

export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
key: K
): OpenAiGlobals[K] | undefined {
return useSyncExternalStore(
(onChange) => {
const handleSetGlobal = (event: CustomEvent) => {
if (event.detail?.globals?.[key] !== undefined) {
onChange();
}
};
window.addEventListener(
"openai:set_globals",
handleSetGlobal as EventListener
);
return () => {
window.removeEventListener(
"openai:set_globals",
handleSetGlobal as EventListener
);
};
},
() => window.openai?.[key]
);
}

How it works:

  1. useSyncExternalStore is React's hook for external state
  2. Listens for openai:set_globals events from ChatGPT
  3. Returns the current value of the requested key
  4. Components using this hook re-render when values change

Using useOpenAiGlobal

function TaskStats() {
const toolOutput = useOpenAiGlobal("toolOutput");
const theme = useOpenAiGlobal("theme");

return (
<div className={theme === "dark" ? "bg-gray-800" : "bg-white"}>
<p>Total: {toolOutput?.total ?? 0} tasks</p>
<p>Pending: {toolOutput?.pending ?? 0}</p>
</div>
);
}

The useWidgetState Hook

This hook manages persistent widget state across conversation turns. It hydrates from window.openai.widgetState and syncs changes back.

Create src/hooks/useWidgetState.ts:

import { useState, useCallback, useEffect } from "react";
import { useOpenAiGlobal } from "./useOpenAiGlobal";

export function useWidgetState<T extends Record<string, unknown>>(
initializer: () => T
): [T, (updater: T | ((prev: T) => T)) => void] {
// Get persisted state from host
const hostState = useOpenAiGlobal("widgetState") as T | undefined;

// Initialize with host state or default
const [state, setStateInternal] = useState<T>(() => {
return hostState ?? initializer();
});

// Sync with host state changes
useEffect(() => {
if (hostState) {
setStateInternal(hostState);
}
}, [hostState]);

// Wrapper that also persists to host
const setState = useCallback(
(updater: T | ((prev: T) => T)) => {
setStateInternal((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater;
// Persist to host
window.openai?.setWidgetState?.(next);
return next;
});
},
[]
);

return [state, setState];
}

Key behaviors:

  • Hydrates from window.openai.widgetState on mount
  • Calls setWidgetState on every update for persistence
  • Works like regular React state otherwise

Using useWidgetState

function TaskList() {
const [widgetState, setWidgetState] = useWidgetState(() => ({
selectedIds: [] as number[],
filter: "all" as "all" | "pending" | "done",
}));

const toggleSelection = (taskId: number) => {
setWidgetState((prev) => ({
...prev,
selectedIds: prev.selectedIds.includes(taskId)
? prev.selectedIds.filter((id) => id !== taskId)
: [...prev.selectedIds, taskId],
}));
};

// State persists across conversation turns!
return (
<div>
<p>Selected: {widgetState.selectedIds.length}</p>
{/* ... */}
</div>
);
}

Using apps-sdk-ui Components

The @openai/apps-sdk-ui library provides components styled to match ChatGPT. Import and use them directly:

import { Button } from "@openai/apps-sdk-ui/components/Button";
import { Badge } from "@openai/apps-sdk-ui/components/Badge";

function TaskItem({ task, onToggle, onDelete }) {
return (
<div className="flex items-center gap-3 p-3 border-b">
<Button
variant={task.done ? "secondary" : "primary"}
size="sm"
onClick={() => onToggle(task.id)}
>
{task.done ? "Undo" : "Complete"}
</Button>

<span className={task.done ? "line-through text-gray-400" : ""}>
{task.title}
</span>

{task.done && (
<Badge variant="success">Done</Badge>
)}

<Button
variant="destructive"
size="sm"
onClick={() => onDelete(task.id)}
>
Delete
</Button>
</div>
);
}

Available Components

ComponentUse Case
ButtonPrimary actions, variants: primary, secondary, destructive
BadgeStatus indicators, task categories
TextLinkNavigation within widget
ButtonLinkLink styled as button
Icon setsCalendar, Phone, Maps, Members, Invoice

Complete React TaskManager

Here's TaskManager rewritten in React:

// src/TaskWidget.tsx
import { useOpenAiGlobal } from "./hooks/useOpenAiGlobal";
import { useWidgetState } from "./hooks/useWidgetState";
import { Button } from "@openai/apps-sdk-ui/components/Button";
import { Badge } from "@openai/apps-sdk-ui/components/Badge";
import { useState } from "react";

interface Task {
id: number;
title: string;
done: boolean;
}

interface WidgetState {
selectedIds: number[];
}

export function TaskWidget() {
const toolOutput = useOpenAiGlobal("toolOutput") as { total: number; pending: number } | undefined;
const metadata = useOpenAiGlobal("toolResponseMetadata") as { tasks: Task[] } | undefined;
const theme = useOpenAiGlobal("theme");

const [widgetState, setWidgetState] = useWidgetState<WidgetState>(() => ({
selectedIds: [],
}));

const [newTaskTitle, setNewTaskTitle] = useState("");

const tasks = metadata?.tasks ?? [];

const toggleSelection = (taskId: number) => {
setWidgetState((prev) => ({
...prev,
selectedIds: prev.selectedIds.includes(taskId)
? prev.selectedIds.filter((id) => id !== taskId)
: [...prev.selectedIds, taskId],
}));
};

const handleComplete = async (taskId: number) => {
await window.openai?.callTool?.("complete_task", { task_id: taskId });
refresh();
};

const handleDelete = async (taskId: number) => {
setWidgetState((prev) => ({
...prev,
selectedIds: prev.selectedIds.filter((id) => id !== taskId),
}));
await window.openai?.callTool?.("delete_task", { task_id: taskId });
refresh();
};

const handleAddTask = () => {
if (!newTaskTitle.trim()) return;
window.openai?.sendFollowUpMessage?.({ prompt: `Add task: ${newTaskTitle}` });
setNewTaskTitle("");
};

const handleBulkDelete = async () => {
for (const id of widgetState.selectedIds) {
await window.openai?.callTool?.("delete_task", { task_id: id });
}
setWidgetState((prev) => ({ ...prev, selectedIds: [] }));
refresh();
};

const refresh = () => {
window.openai?.sendFollowUpMessage?.({ prompt: "Show my tasks" });
};

return (
<div className={`p-4 rounded-xl ${theme === "dark" ? "bg-gray-800 text-white" : "bg-white"}`}>
<h2 className="text-xl font-semibold mb-2">TaskManager</h2>

{toolOutput && (
<p className="text-sm text-gray-500 mb-4">
{toolOutput.total} tasks ({toolOutput.pending} pending)
</p>
)}

{/* Add Task Form */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleAddTask()}
placeholder="Add a task..."
className="flex-1 px-3 py-2 border rounded-md"
/>
<Button onClick={handleAddTask}>Add</Button>
</div>

{/* Bulk Actions */}
{widgetState.selectedIds.length > 0 && (
<div className="mb-4">
<Button variant="destructive" onClick={handleBulkDelete}>
Delete {widgetState.selectedIds.length} Selected
</Button>
</div>
)}

{/* Task List */}
<div className="space-y-2">
{tasks.length === 0 ? (
<p className="text-gray-400 text-center py-4">No tasks yet</p>
) : (
tasks.map((task) => (
<div
key={task.id}
className={`flex items-center gap-3 p-3 rounded-lg border ${
widgetState.selectedIds.includes(task.id)
? "bg-blue-50 border-blue-200"
: "border-gray-200"
}`}
>
{/* Selection checkbox */}
<button
onClick={() => toggleSelection(task.id)}
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
widgetState.selectedIds.includes(task.id)
? "border-blue-500 bg-blue-100"
: "border-gray-300"
}`}
>
{widgetState.selectedIds.includes(task.id) && "●"}
</button>

{/* Complete button */}
<Button
variant={task.done ? "secondary" : "primary"}
size="sm"
onClick={() => handleComplete(task.id)}
>
{task.done ? "Undo" : "✓"}
</Button>

{/* Task title */}
<span className={`flex-1 ${task.done ? "line-through text-gray-400" : ""}`}>
{task.title}
</span>

{/* Status badge */}
{task.done && <Badge variant="success">Done</Badge>}

{/* Delete button */}
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(task.id)}
>
×
</Button>
</div>
))
)}
</div>

{/* Refresh button */}
<Button className="mt-4 w-full" onClick={refresh}>
Refresh
</Button>
</div>
);
}

Entry Point and Bundling

Create the entry point that renders your widget:

// src/main.tsx
import { createRoot } from "react-dom/client";
import { TaskWidget } from "./TaskWidget";
import "./main.css";

const container = document.getElementById("root");
if (container) {
createRoot(container).render(<TaskWidget />);
}

Build Script

Add to package.json:

{
"scripts": {
"build": "esbuild src/main.tsx --bundle --format=esm --outfile=dist/widget.js --loader:.css=css",
"watch": "esbuild src/main.tsx --bundle --format=esm --outfile=dist/widget.js --loader:.css=css --watch"
}
}

Build the bundle:

npm run build

This creates dist/widget.js—a single file containing React, your components, and styles.

Serving the React Widget

Update your FastMCP server to serve the bundled widget:

from pathlib import Path

# Read the bundled widget
WIDGET_JS = Path("../web/dist/widget.js").read_text()

WIDGET_HTML = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root"></div>
<script type="module">{WIDGET_JS}</script>
</body>
</html>'''

Or serve the JS file separately and reference it:

WIDGET_HTML = '''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="root"></div>
<script type="module" src="https://your-domain.com/widget.js"></script>
</body>
</html>'''

Vanilla JS vs React: When to Use Each

ScenarioRecommendation
Simple display widgetVanilla JS
Complex state managementReact
Team projectReact (consistent patterns)
Quick prototypeVanilla JS
Production appReact + apps-sdk-ui
Learning ChatGPT AppsStart with Vanilla JS, then React

Both approaches use the same window.openai API. React just provides better organization for complex widgets.

Summary

You learned the React approach to ChatGPT Apps:

ConceptPurpose
@openai/apps-sdk-uiOfficial component library matching ChatGPT design
useOpenAiGlobalSubscribe to window.openai values reactively
useWidgetStatePersistent state across conversation turns
esbuild bundlingSingle-file output for widget embedding

The underlying architecture remains the same—MCP server returning text/html+skybridge widgets. React is just a better way to build complex widget UIs.

Try With AI

Prompt 1: Add Dark Mode Support

The TaskWidget has a theme variable from useOpenAiGlobal("theme"). Update all components to properly support dark mode. Use Tailwind's dark: prefix classes. The widget should automatically match ChatGPT's current theme.

What you're learning: Responsive theming using ChatGPT's theme signal.

Prompt 2: Create a Custom Hook

Create a useTaskActions hook that encapsulates all the task operations (complete, delete, add, refresh). It should return { completeTask, deleteTask, addTask, refresh } functions. Update TaskWidget to use this hook instead of inline handlers.

What you're learning: Custom hooks for reusable logic—a React best practice.

Prompt 3: Add Display Mode Toggle

Add a button that toggles between inline and fullscreen display modes using window.openai.requestDisplayMode. Show different UI layouts based on the current displayMode from useOpenAiGlobal("displayMode").

What you're learning: Integrating display mode controls with React state.