Managing State
How to persist view state across renders and re-mounts.
Decision Tree
Section titled “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? → useViewState│ (Form inputs, selected items, flags — persisted by the host)│└── Complex state with actions? → createStore (Multiple related values, computed state, async actions)useViewState
Section titled “useViewState”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 view remounts.
import { useViewState } from "@usefractal/frac/web";
function CounterView() { const [state, setState] = useViewState({ count: 0 });
if (!state) return null;
return ( <div> <p>Count: {state.count}</p> <button onClick={() => setState(prev => ({ count: prev.count + 1 }))}> Increment </button> </div> );}const [state, setState] = useViewState<T>(defaultState);state: Current state (ornullif not yet initialized)setState: Update function (accepts value or updater function)defaultState: Initial state (used if no persisted state exists)
Patterns
Section titled “Patterns”Form state:
const [formData, setFormData] = useViewState({ name: "", email: "", message: "",});
const updateField = (field: string, value: string) => { setFormData(prev => ({ ...prev, [field]: value }));};Selection state:
const [selected, setSelected] = useViewState<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
Section titled “createStore”A Zustand store that automatically syncs with the host’s persistent state. Unlike useViewState, this provides actions, computed values, and middleware support for complex state management.
import { createStore } from "@usefractal/frac/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 loadUsage:
function CartView() { 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
Section titled “Automatic Persistence”createStore automatically:
- Syncs state to the host’s persistent storage
- Restores state from the host on load
- Filters out functions (actions) during serialization
Comparison Table
Section titled “Comparison Table”| Feature | useViewState | 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
Section titled “Combining with data-llm”State management is separate from LLM context. Use both when needed:
function ProductListView() { const [selected, setSelected] = useViewState<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> );}useViewStatepersists the selectiondata-llmtells the model what the user sees
When State Persists
Section titled “When State Persists”View state persists across re-renders, re-mounts, and display mode changes. frac handles the storage mechanism automatically.
View state resets when:
- A new conversation starts
- The tool is called again with new input
- The user explicitly clears it
Migration from useState
Section titled “Migration from useState”If you’re using React useState and losing state on re-renders:
// Before: State lost on re-mountconst [selected, setSelected] = useState<string | null>(null);
// After: State persistsconst [selected, setSelected] = useViewState<string | null>(null);The API is intentionally similar to useState for easy migration.
Related
Section titled “Related”- useViewState API
- createStore API
- LLM Context Sync
- MCP Apps - MCP Apps limitations
- Apps SDK (ChatGPT) - Apps SDK features