How to persist widget state across renders and re-mounts.
Decision Tree
What kind of state do you need?
├── Need to sync with LLM? → data-llm attribute
│ (The model needs to know what user is viewing)
│
├── Simple key/value state? → useWidgetState
│ (Form inputs, selected items, flags — persisted by the host)
│
└── Complex state with actions? → createStore
(Multiple related values, computed state, async actions)
Persistent state that survives re-renders and display mode changes. Unlike React’s useState, this state is stored by the host and restored when your widget remounts.
import { useWidgetState } from "skybridge/web";
function CounterWidget() {
const [state, setState] = useWidgetState({ count: 0 });
if (!state) return null;
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => setState(prev => ({ count: prev.count + 1 }))}>
Increment
</button>
</div>
);
}
API
const [state, setState] = useWidgetState<T>(defaultState);
state: Current state (or null if not yet initialized)
setState: Update function (accepts value or updater function)
defaultState: Initial state (used if no persisted state exists)
Patterns
Form state:
const [formData, setFormData] = useWidgetState({
name: "",
email: "",
message: "",
});
const updateField = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
Selection state:
const [selected, setSelected] = useWidgetState<Set<string>>(new Set());
const toggleItem = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
createStore
A Zustand store that automatically syncs with the host’s persistent state. Unlike useWidgetState, this provides actions, computed values, and middleware support for complex state management.
import { createStore } from "skybridge/web";
type CartItem = { id: string; name: string; price: number; quantity: number };
type CartState = {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
total: () => number;
clear: () => void;
};
const useCartStore = createStore<CartState>((set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find(existingItem => existingItem.id === item.id);
if (existing) {
return {
items: state.items.map(existingItem =>
existingItem.id === item.id ? { ...existingItem, quantity: existingItem.quantity + 1 } : existingItem
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id),
})),
updateQuantity: (id, quantity) => set((state) => ({
items: state.items.map(item =>
item.id === id ? { ...item, quantity } : item
),
})),
total: () => get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
clear: () => set({ items: [] }),
}), { items: [] }); // Default state for first load
Usage:
function CartWidget() {
const items = useCartStore(state => state.items);
const addItem = useCartStore(state => state.addItem);
const total = useCartStore(state => state.total());
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.name} x{item.quantity}</li>
))}
</ul>
<p>Total: ${total}</p>
</div>
);
}
Automatic Persistence
createStore automatically:
- Syncs state to the host’s persistent storage (Apps SDK:
window.openai.setWidgetState(), MCP Apps: polyfilled)
- Restores state from the host on load (Apps SDK:
window.openai.widgetState, MCP Apps: polyfilled)
- Filters out functions (actions) during serialization
MCP Apps Limitation: In MCP Apps, state persistence is polyfilled and does not survive widget re-renders. State is only maintained within the current widget render cycle.
Comparison Table
| Feature | useWidgetState | createStore |
|---|
| Setup complexity | Simple | Moderate |
| State shape | Any object | Object with actions |
| Multiple consumers | Re-renders all | Selective subscriptions |
| Computed values | Manual | Built-in with get() |
| Async actions | Manual with callbacks | Built-in |
| Best for | Simple forms, flags | Shopping carts, complex flows |
Combining with data-llm
State management is separate from LLM context. Use both when needed:
function ProductListWidget() {
const [selected, setSelected] = useWidgetState<string | null>(null);
const products = useToolInfo<Product[]>().output?.structuredContent.products;
const selectedProduct = products?.find(product => product.id === selected);
return (
<div data-llm={selected
? `User selected: ${selectedProduct?.name}`
: "User browsing product list"
}>
{products?.map(product => (
<div
key={product.id}
onClick={() => setSelected(product.id)}
data-llm={`Product: ${product.name} - $${product.price}`}
>
{product.name}
</div>
))}
</div>
);
}
useWidgetState persists the selection
data-llm tells the model what the user sees
When State Persists
In Apps SDK (ChatGPT), widget state persists:
- When the widget re-renders (component update)
- When the user scrolls away and back
- When the display mode changes
In MCP Apps, widget state only persists:
- Within the current widget render cycle (component updates)
- State is lost when the widget is re-mounted or the tool is called again
Widget state resets when:
- A new conversation starts
- The tool is called again with new input
- The user explicitly clears it
Migration from useState
If you’re using React useState and losing state on re-renders:
// Before: State lost on re-mount
const [selected, setSelected] = useState<string | null>(null);
// After: State persists
const [selected, setSelected] = useWidgetState<string | null>(null);
The API is intentionally similar to useState for easy migration.