Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
DEPRECATED: Replaced by mcp-app-builder. Previously used to build MCP servers with tools, resources, and prompts via mcp-use.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/widgets/state.md
1# Widget State23Widgets manage their own UI state (selections, filters, tabs, pagination). Never create tools to manage widget state.45**Key principle:** UI state lives in the widget. Server state lives in tools.67---89## Widget State vs Tool State1011### Widget State (UI State)12**Managed by widget with `useState` or `setState`:**13- Current selected item14- Active tab15- Filter settings16- Sort order17- Pagination page18- Expanded/collapsed sections19- Form input values (before submission)2021### Tool State (Server State)22**Managed by server, returned in tool response:**23- List of items24- User data25- API results26- Computation results27- Database queries2829---3031## Using React useState3233Standard React state management works in widgets:3435```tsx36import { useState } from "react";37import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react";38import { z } from "zod";3940export const widgetMetadata: WidgetMetadata = {41description: "Product list with filtering",42props: z.object({43products: z.array(z.object({44id: z.string(),45name: z.string(),46category: z.string(),47price: z.number()48}))49}),50exposeAsTool: false51};5253export default function ProductList() {54const { props, isPending } = useWidget();55const [selectedCategory, setSelectedCategory] = useState<string>("all");56const [sortBy, setSortBy] = useState<"name" | "price">("name");5758if (isPending) {59return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;60}6162// Filter and sort based on state63const filtered = selectedCategory === "all"64? props.products65: props.products.filter(p => p.category === selectedCategory);6667const sorted = [...filtered].sort((a, b) => {68if (sortBy === "name") return a.name.localeCompare(b.name);69return a.price - b.price;70});7172const categories = ["all", ...new Set(props.products.map(p => p.category))];7374return (75<McpUseProvider autoSize>76<div style={{ padding: 20 }}>77{/* Category filter */}78<div style={{ marginBottom: 16 }}>79{categories.map(cat => (80<button81key={cat}82onClick={() => setSelectedCategory(cat)}83style={{84padding: "8px 16px",85margin: "0 4px",86backgroundColor: selectedCategory === cat ? "#007bff" : "#f0f0f0",87color: selectedCategory === cat ? "white" : "black",88border: "none",89borderRadius: 4,90cursor: "pointer"91}}92>93{cat}94</button>95))}96</div>9798{/* Sort controls */}99<div style={{ marginBottom: 16 }}>100<label>101Sort by:102<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)} style={{ marginLeft: 8 }}>103<option value="name">Name</option>104<option value="price">Price</option>105</select>106</label>107</div>108109{/* Product list */}110<div>111{sorted.map(product => (112<div key={product.id} style={{ padding: 12, border: "1px solid #ddd", marginBottom: 8 }}>113<h3>{product.name}</h3>114<p>Category: {product.category} | ${product.price}</p>115</div>116))}117</div>118</div>119</McpUseProvider>120);121}122```123124**Pattern:**125- Tool provides data (products)126- Widget manages UI state (selectedCategory, sortBy)127- Widget renders filtered/sorted view128- No additional tool calls needed129130---131132## Using setState from useWidget133134The `setState` method from `useWidget()` is an alternative to React's `useState` with automatic state persistence across widget interactions. See [basics.md](basics.md#usewidget-hook) for full `useWidget()` API reference.135136**When to use `setState` vs `useState`:**137- Use `useState` for simple, ephemeral UI state (resets on widget unmount)138- Use `setState` from `useWidget` for state that persists across interactions139140---141142## Selection State143144Track which item(s) are selected:145146```tsx147import { useState } from "react";148149export default function ItemSelector() {150const { props, isPending } = useWidget();151const [selectedId, setSelectedId] = useState<string | null>(null);152153if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;154155return (156<McpUseProvider autoSize>157<div>158{props.items.map(item => (159<div160key={item.id}161onClick={() => setSelectedId(item.id)}162style={{163padding: 12,164border: `2px solid ${selectedId === item.id ? "#007bff" : "#ddd"}`,165marginBottom: 8,166cursor: "pointer"167}}168>169{item.name}170</div>171))}172</div>173</McpUseProvider>174);175}176```177178### Multi-Select179180```tsx181const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());182183const toggleSelection = (id: string) => {184const newSelection = new Set(selectedIds);185if (newSelection.has(id)) {186newSelection.delete(id);187} else {188newSelection.add(id);189}190setSelectedIds(newSelection);191};192193return (194<McpUseProvider autoSize>195<div>196{props.items.map(item => (197<div198key={item.id}199onClick={() => toggleSelection(item.id)}200style={{201padding: 12,202backgroundColor: selectedIds.has(item.id) ? "#e3f2fd" : "white",203border: "1px solid #ddd"204}}205>206<input207type="checkbox"208checked={selectedIds.has(item.id)}209readOnly210/>211{item.name}212</div>213))}214</div>215</McpUseProvider>216);217```218219---220221## Tab State222223Manage tabs without additional tool calls:224225```tsx226const [activeTab, setActiveTab] = useState<"overview" | "details" | "history">("overview");227228return (229<McpUseProvider autoSize>230<div>231{/* Tab buttons */}232<div style={{ borderBottom: "1px solid #ddd", marginBottom: 16 }}>233{["overview", "details", "history"].map(tab => (234<button235key={tab}236onClick={() => setActiveTab(tab as any)}237style={{238padding: "8px 16px",239border: "none",240borderBottom: activeTab === tab ? "2px solid #007bff" : "none",241background: "none",242cursor: "pointer"243}}244>245{tab.charAt(0).toUpperCase() + tab.slice(1)}246</button>247))}248</div>249250{/* Tab content */}251{activeTab === "overview" && <div>{/* Overview content */}</div>}252{activeTab === "details" && <div>{/* Details content */}</div>}253{activeTab === "history" && <div>{/* History content */}</div>}254</div>255</McpUseProvider>256);257```258259---260261## Pagination State262263Paginate large lists client-side:264265```tsx266const [currentPage, setCurrentPage] = useState(1);267const itemsPerPage = 10;268269const totalPages = Math.ceil(props.items.length / itemsPerPage);270const startIndex = (currentPage - 1) * itemsPerPage;271const currentItems = props.items.slice(startIndex, startIndex + itemsPerPage);272273return (274<McpUseProvider autoSize>275<div>276{/* Items */}277<div>278{currentItems.map(item => (279<div key={item.id}>{item.name}</div>280))}281</div>282283{/* Pagination controls */}284<div style={{ marginTop: 16, display: "flex", gap: 8 }}>285<button286onClick={() => setCurrentPage(p => Math.max(1, p - 1))}287disabled={currentPage === 1}288>289Previous290</button>291292<span>293Page {currentPage} of {totalPages}294</span>295296<button297onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}298disabled={currentPage === totalPages}299>300Next301</button>302</div>303</div>304</McpUseProvider>305);306```307308---309310## Filter State311312Complex filtering:313314```tsx315interface Filters {316search: string;317category: string;318priceMin: number;319priceMax: number;320}321322const [filters, setFilters] = useState<Filters>({323search: "",324category: "all",325priceMin: 0,326priceMax: 1000327});328329const filteredItems = props.items.filter(item => {330if (filters.search && !item.name.toLowerCase().includes(filters.search.toLowerCase())) {331return false;332}333if (filters.category !== "all" && item.category !== filters.category) {334return false;335}336if (item.price < filters.priceMin || item.price > filters.priceMax) {337return false;338}339return true;340});341342return (343<McpUseProvider autoSize>344<div>345{/* Filter controls */}346<div style={{ marginBottom: 16 }}>347<input348type="text"349placeholder="Search..."350value={filters.search}351onChange={e => setFilters({ ...filters, search: e.target.value })}352style={{ padding: 8, marginRight: 8 }}353/>354355<select356value={filters.category}357onChange={e => setFilters({ ...filters, category: e.target.value })}358style={{ padding: 8, marginRight: 8 }}359>360<option value="all">All Categories</option>361{/* ... category options */}362</select>363364<input365type="number"366value={filters.priceMin}367onChange={e => setFilters({ ...filters, priceMin: Number(e.target.value) })}368placeholder="Min price"369style={{ width: 80, padding: 8, marginRight: 8 }}370/>371372<input373type="number"374value={filters.priceMax}375onChange={e => setFilters({ ...filters, priceMax: Number(e.target.value) })}376placeholder="Max price"377style={{ width: 80, padding: 8 }}378/>379</div>380381{/* Filtered items */}382<div>383{filteredItems.map(item => (384<div key={item.id}>{item.name} - ${item.price}</div>385))}386</div>387</div>388</McpUseProvider>389);390```391392---393394## Expand/Collapse State395396Accordion or expandable sections:397398```tsx399const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());400401const toggleExpand = (id: string) => {402const newExpanded = new Set(expandedIds);403if (newExpanded.has(id)) {404newExpanded.delete(id);405} else {406newExpanded.add(id);407}408setExpandedIds(newExpanded);409};410411return (412<McpUseProvider autoSize>413<div>414{props.items.map(item => (415<div key={item.id} style={{ marginBottom: 8 }}>416<div417onClick={() => toggleExpand(item.id)}418style={{419padding: 12,420backgroundColor: "#f5f5f5",421cursor: "pointer",422display: "flex",423justifyContent: "space-between"424}}425>426<span>{item.title}</span>427<span>{expandedIds.has(item.id) ? "▼" : "▶"}</span>428</div>429430{expandedIds.has(item.id) && (431<div style={{ padding: 12, border: "1px solid #ddd" }}>432{item.details}433</div>434)}435</div>436))}437</div>438</McpUseProvider>439);440```441442---443444## Form State445446Track form inputs before submission:447448```tsx449const [formData, setFormData] = useState({450name: "",451email: "",452message: ""453});454455const handleChange = (field: string, value: string) => {456setFormData(prev => ({ ...prev, [field]: value }));457};458459return (460<McpUseProvider autoSize>461<form onSubmit={(e) => {462e.preventDefault();463// Handle submission (see interactivity.md)464}}>465<input466type="text"467value={formData.name}468onChange={(e) => handleChange("name", e.target.value)}469placeholder="Name"470/>471472<input473type="email"474value={formData.email}475onChange={(e) => handleChange("email", e.target.value)}476placeholder="Email"477/>478479<textarea480value={formData.message}481onChange={(e) => handleChange("message", e.target.value)}482placeholder="Message"483/>484485<button type="submit">Send</button>486</form>487</McpUseProvider>488);489```490491---492493## State Initialization494495Initialize state based on props:496497```tsx498const [selectedCategory, setSelectedCategory] = useState<string>("");499500// Initialize when props load501useEffect(() => {502if (props.categories && props.categories.length > 0 && !selectedCategory) {503setSelectedCategory(props.categories[0]);504}505}, [props.categories, selectedCategory]);506```507508**Note:** Lazy initialization like `useState(() => props.categories?.[0] || "all")` won't work here — on the first render `isPending` is `true` and `props` is `{}`, so the initializer always resolves to `"all"`. The `useEffect` pattern above is the correct approach for props that arrive asynchronously.509510---511512## Common Patterns513514### Search + Filter + Sort515```tsx516const [search, setSearch] = useState("");517const [category, setCategory] = useState("all");518const [sortBy, setSortBy] = useState("name");519520let filtered = props.items;521522// Apply search523if (search) {524filtered = filtered.filter(item =>525item.name.toLowerCase().includes(search.toLowerCase())526);527}528529// Apply category filter530if (category !== "all") {531filtered = filtered.filter(item => item.category === category);532}533534// Apply sort535filtered.sort((a, b) => {536if (sortBy === "name") return a.name.localeCompare(b.name);537if (sortBy === "price") return a.price - b.price;538return 0;539});540```541542### Master-Detail View543```tsx544const [selectedId, setSelectedId] = useState<string | null>(null);545546const selectedItem = selectedId547? props.items.find(item => item.id === selectedId)548: null;549550return (551<div style={{ display: "flex", gap: 16 }}>552{/* Master list */}553<div style={{ flex: 1 }}>554{props.items.map(item => (555<div556key={item.id}557onClick={() => setSelectedId(item.id)}558style={{559padding: 12,560backgroundColor: selectedId === item.id ? "#e3f2fd" : "white"561}}562>563{item.name}564</div>565))}566</div>567568{/* Detail panel */}569<div style={{ flex: 2 }}>570{selectedItem ? (571<div>572<h2>{selectedItem.name}</h2>573<p>{selectedItem.description}</p>574</div>575) : (576<p>Select an item to view details</p>577)}578</div>579</div>580);581```582583---584585## Anti-Patterns586587### ❌ Don't Create Tools for UI State588```typescript589// ❌ Bad - Tool for UI state590server.tool(591{ name: "set-filter", schema: z.object({ category: z.string() }) },592async ({ category }) => {593// This is wrong! Filters should be widget state594}595);596597// ✅ Good - Widget manages its own filters598const [filter, setFilter] = useState("all");599```600601### ❌ Don't Call Tools for Filtering/Sorting602```typescript603// ❌ Bad - Using a tool call for client-side filtering604const { callTool: filterItems } = useCallTool("filter-items");605<button onClick={() => filterItems({ category: "electronics" })}>606Filter607</button>608609// ✅ Good - Filter in widget610<button onClick={() => setCategory("electronics")}>611Filter612</button>613```614615### ❌ Don't Store UI State in Props616```typescript617// ❌ Bad - Trying to mutate props618props.selectedId = "123"; // Error! Props are read-only619620// ✅ Good - Use state621const [selectedId, setSelectedId] = useState<string | null>(null);622```623624---625626## Best Practices6276281. **Keep state local** - Don't lift state unless necessary6292. **Initialize from props** - Use props as initial data, state for UI6303. **Use descriptive names** - `selectedCategory` not `filter`6314. **Reset state appropriately** - When props change, update dependent state6325. **Avoid unnecessary re-renders** - Use `useMemo` for expensive computations633634---635636## Next Steps637638- **Add interactivity** → [interactivity.md](interactivity.md)639- **Style widgets** → [ui-guidelines.md](ui-guidelines.md)640- **Advanced patterns** → [advanced.md](advanced.md)641