Widget Interactivity
Widgets interact with the outside world using hooks from mcp-use/react. useCallTool() provides tool calling with built-in state management. sendFollowUpMessage from useWidget() triggers LLM conversation turns.
Use useCallTool() for: Creating items, updating data, triggering actions, submitting forms Use sendFollowUpMessage for: Asking the AI to analyze, compare, summarize, or respond based on widget context
useCallTool() Basics
useCallTool() provides a TanStack Query-like state machine for calling MCP tools:
import { useCallTool } from "mcp-use/react";
const { callTool, callToolAsync, isPending, isSuccess, isError, data, error } =
useCallTool("tool-name");
// Fire-and-forget with optional callbacks
callTool({ param: "value" }, {
onSuccess: (result) => console.log(result.structuredContent),
onError: (err) => console.error(err),
onSettled: () => hideSpinner(),
});
// Or async/await
const result = await callToolAsync({ param: "value" });State flags:
| Property | Description |
|---|---|
isPending | Tool is executing |
isSuccess | Succeeded — data is available |
isError | Failed — error is available |
isIdle | No call made yet |
callTool | Fire-and-forget; optional onSuccess/onError/onSettled callbacks |
callToolAsync | Returns Promise<CallToolResult> |
Type inference: When using mcp-use dev, types for tool names, inputs, and outputs are auto-generated to .mcp-use/tool-registry.d.ts. The hook is fully typed with autocomplete.
Simple Button Action
import { McpUseProvider, useWidget, useCallTool, type WidgetMetadata } from "mcp-use/react";
import { z } from "zod";
export const widgetMetadata: WidgetMetadata = {
description: "Todo list with actions",
props: z.object({
todos: z.array(z.object({
id: z.string(),
title: z.string(),
completed: z.boolean()
}))
}),
exposeAsTool: false
};
export default function TodoList() {
const { props, isPending: isLoading } = useWidget();
const { callTool, isPending } = useCallTool("toggle-todo");
if (isLoading) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
return (
<McpUseProvider autoSize>
<div>
{props.todos.map(todo => (
<div key={todo.id} style={{ display: "flex", gap: 8, padding: 8 }}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => callTool({ id: todo.id, completed: !todo.completed })}
disabled={isPending}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.title}
</span>
</div>
))}
</div>
</McpUseProvider>
);
}Corresponding tool:
server.tool(
{
name: "toggle-todo",
description: "Toggle todo completion status",
schema: z.object({
id: z.string(),
completed: z.boolean()
})
},
async ({ id, completed }) => {
await updateTodo(id, { completed });
return text(`Todo ${completed ? "completed" : "uncompleted"}`);
}
);Form Submission
isPending from useCallTool replaces manual submitting state:
import { useState } from "react";
import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";
export default function CreateItemWidget() {
const { props, isPending: isLoading } = useWidget();
const { callTool, isPending } = useCallTool("create-todo");
const [title, setTitle] = useState("");
if (isLoading) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
callTool({ title }, {
onSuccess: () => setTitle(""),
onError: () => alert("Failed to create todo"),
});
};
return (
<McpUseProvider autoSize>
<div style={{ padding: 20 }}>
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="New todo..."
disabled={isPending}
style={{ padding: 8, width: 300, marginRight: 8 }}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Add Todo"}
</button>
</form>
<div style={{ marginTop: 16 }}>
{props.todos.map(todo => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
</div>
</McpUseProvider>
);
}Corresponding tool:
server.tool(
{
name: "create-todo",
schema: z.object({
title: z.string().describe("Todo title")
})
},
async ({ title }) => {
const todo = await createTodo(title);
return text(`Created todo: ${todo.title}`);
}
);Delete Action
const { callTool: deleteTodo, isPending: isDeleting } = useCallTool("delete-todo");
const handleDelete = (id: string) => {
if (!confirm("Are you sure you want to delete this item?")) return;
deleteTodo({ id }, {
onError: () => alert("Failed to delete item"),
});
};
return (
<McpUseProvider autoSize>
<div>
{props.todos.map(todo => (
<div key={todo.id} style={{ display: "flex", justifyContent: "space-between", padding: 8 }}>
<span>{todo.title}</span>
<button onClick={() => handleDelete(todo.id)} disabled={isDeleting}>Delete</button>
</div>
))}
</div>
</McpUseProvider>
);Optimistic Updates
Update UI immediately, then call tool:
import { useState, useEffect } from "react";
import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";
interface Todo {
id: string;
title: string;
completed: boolean;
}
export default function OptimisticWidget() {
const { props, isPending: isLoading } = useWidget<{ todos: Todo[] }>();
const { callToolAsync } = useCallTool("toggle-todo");
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
if (!isLoading && props.todos) {
setTodos(props.todos);
}
}, [isLoading, props.todos]);
const handleToggle = async (id: string) => {
// Optimistic update
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
try {
await callToolAsync({ id });
} catch {
// Revert on failure
setTodos(props.todos);
alert("Failed to update todo");
}
};
if (isLoading) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
return (
<McpUseProvider autoSize>
<div>
{todos.map(todo => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
{todo.title}
</div>
))}
</div>
</McpUseProvider>
);
}Action Buttons
Multiple actions per item — declare a hook for each tool:
const { callTool: editItem } = useCallTool("edit-item");
const { callTool: duplicateItem } = useCallTool("duplicate-item");
const { callTool: archiveItem } = useCallTool("archive-item");
const { callTool: deleteItem } = useCallTool("delete-item");
return (
<McpUseProvider autoSize>
<div>
{props.items.map(item => (
<div key={item.id} style={{ padding: 12, border: "1px solid #ddd", marginBottom: 8 }}>
<h3>{item.title}</h3>
<p>{item.description}</p>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => editItem({ id: item.id })}>Edit</button>
<button onClick={() => duplicateItem({ id: item.id })}>Duplicate</button>
<button onClick={() => archiveItem({ id: item.id })}>Archive</button>
<button onClick={() => deleteItem({ id: item.id })} style={{ color: "red" }}>
Delete
</button>
</div>
</div>
))}
</div>
</McpUseProvider>
);Inline Editing
import { useState } from "react";
import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";
export default function EditableList() {
const { props, isPending: isLoading } = useWidget();
const { callToolAsync, isPending: isSaving } = useCallTool("update-item");
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const startEdit = (id: string, currentValue: string) => {
setEditingId(id);
setEditValue(currentValue);
};
const saveEdit = async (id: string) => {
try {
await callToolAsync({ id, title: editValue });
setEditingId(null);
} catch {
alert("Failed to save");
}
};
const cancelEdit = () => {
setEditingId(null);
setEditValue("");
};
if (isLoading) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
return (
<McpUseProvider autoSize>
<div>
{props.items.map(item => (
<div key={item.id} style={{ padding: 8, display: "flex", gap: 8 }}>
{editingId === item.id ? (
<>
<input
type="text"
value={editValue}
onChange={e => setEditValue(e.target.value)}
autoFocus
/>
<button onClick={() => saveEdit(item.id)} disabled={isSaving}>Save</button>
<button onClick={cancelEdit}>Cancel</button>
</>
) : (
<>
<span>{item.title}</span>
<button onClick={() => startEdit(item.id, item.title)}>Edit</button>
</>
)}
</div>
))}
</div>
</McpUseProvider>
);
}Batch Actions
Select multiple items and act on them:
import { useState } from "react";
import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";
export default function BatchActions() {
const { props, isPending: isLoading } = useWidget();
const { callTool: archiveItems, isPending: isArchiving } = useCallTool("archive-items");
const { callTool: deleteItems, isPending: isDeleting } = useCallTool("delete-items");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const processing = isArchiving || isDeleting;
const toggleSelection = (id: string) => {
const newSelection = new Set(selectedIds);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
setSelectedIds(newSelection);
};
const handleBatchArchive = () => {
archiveItems({ ids: Array.from(selectedIds) }, {
onSuccess: () => setSelectedIds(new Set()),
onError: () => alert("Failed to archive items"),
});
};
const handleBatchDelete = () => {
deleteItems({ ids: Array.from(selectedIds) }, {
onSuccess: () => setSelectedIds(new Set()),
onError: () => alert("Failed to delete items"),
});
};
if (isLoading) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
return (
<McpUseProvider autoSize>
<div>
{selectedIds.size > 0 && (
<div style={{ padding: 12, backgroundColor: "#f5f5f5", marginBottom: 16 }}>
<span>{selectedIds.size} selected</span>
<button onClick={handleBatchArchive} disabled={processing} style={{ marginLeft: 8 }}>
Archive
</button>
<button onClick={handleBatchDelete} disabled={processing} style={{ marginLeft: 8 }}>
Delete
</button>
</div>
)}
{props.items.map(item => (
<div key={item.id} style={{ padding: 8, display: "flex", gap: 8 }}>
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => toggleSelection(item.id)}
/>
<span>{item.title}</span>
</div>
))}
</div>
</McpUseProvider>
);
}Corresponding tool:
server.tool(
{
name: "delete-items",
schema: z.object({
ids: z.array(z.string()).describe("Item IDs to delete")
})
},
async ({ ids }) => {
await Promise.all(ids.map(id => deleteItem(id)));
return text(`Deleted ${ids.length} items`);
}
);Handling Tool Errors
Use isError and error from the hook instead of manual error state:
const { callTool, isError, error, isPending } = useCallTool("some-tool");
return (
<McpUseProvider autoSize>
<div>
{isError && (
<div style={{ padding: 12, backgroundColor: "#ffebee", color: "#c62828", marginBottom: 16 }}>
{error instanceof Error ? error.message : "Action failed"}
</div>
)}
<button onClick={() => callTool({ /* params */ })} disabled={isPending}>
Perform Action
</button>
</div>
</McpUseProvider>
);Per-Item Loading States
For per-item loading when sharing one hook instance, track the active ID separately:
const { callToolAsync } = useCallTool("process-item");
const [loadingId, setLoadingId] = useState<string | null>(null);
const handleAction = async (id: string) => {
setLoadingId(id);
try {
await callToolAsync({ id });
} catch {
alert("Failed");
} finally {
setLoadingId(null);
}
};
return (
<McpUseProvider autoSize>
<div>
{props.items.map(item => (
<div key={item.id}>
<span>{item.title}</span>
<button
onClick={() => handleAction(item.id)}
disabled={loadingId === item.id}
>
{loadingId === item.id ? "Processing..." : "Process"}
</button>
</div>
))}
</div>
</McpUseProvider>
);Confirmation Dialogs
const { callTool: deleteItem } = useCallTool("delete-item");
const handleDelete = (id: string, title: string) => {
if (!confirm(`Are you sure you want to delete "${title}"?`)) return;
deleteItem({ id }, {
onError: () => alert("Failed to delete"),
});
};Or with a custom dialog:
import { useState } from "react";
import { useCallTool } from "mcp-use/react";
const { callToolAsync } = useCallTool("delete-item");
const [confirmDialog, setConfirmDialog] = useState<{ id: string; title: string } | null>(null);
const handleDeleteClick = (id: string, title: string) => {
setConfirmDialog({ id, title });
};
const handleConfirmDelete = async () => {
if (!confirmDialog) return;
try {
await callToolAsync({ id: confirmDialog.id });
setConfirmDialog(null);
} catch {
alert("Failed to delete");
}
};
return (
<McpUseProvider autoSize>
<div>
{props.items.map(item => (
<div key={item.id}>
<span>{item.title}</span>
<button onClick={() => handleDeleteClick(item.id, item.title)}>Delete</button>
</div>
))}
{confirmDialog && (
<div style={{
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0,0,0,0.5)", display: "flex",
alignItems: "center", justifyContent: "center"
}}>
<div style={{ backgroundColor: "white", padding: 24, borderRadius: 8 }}>
<h3>Confirm Delete</h3>
<p>Delete "{confirmDialog.title}"?</p>
<button onClick={handleConfirmDelete}>Delete</button>
<button onClick={() => setConfirmDialog(null)}>Cancel</button>
</div>
</div>
)}
</div>
</McpUseProvider>
);Triggering LLM Responses: sendFollowUpMessage
sendFollowUpMessage from useWidget() sends a message to the conversation and triggers a new LLM turn — as if the user typed it. Use this to let widget interactions drive the conversation.
import { McpUseProvider, useWidget } from "mcp-use/react";
export default function AnalysisWidget() {
const { props, isPending, sendFollowUpMessage } = useWidget();
if (isPending) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
return (
<McpUseProvider autoSize>
<div style={{ padding: 20 }}>
<h2>Results for "{props.query}"</h2>
{props.items.map(item => (
<div key={item.id} style={{ padding: 8, borderBottom: "1px solid #ddd" }}>
<strong>{item.name}</strong> — ${item.price}
</div>
))}
<button
onClick={() => sendFollowUpMessage(
`Compare the top 3 results for "${props.query}" and recommend the best one.`
)}
style={{ marginTop: 16, padding: "8px 16px" }}
>
Ask AI to Compare
</button>
</div>
</McpUseProvider>
);
}Combining with useCallTool
A widget can use both — useCallTool for data mutations and sendFollowUpMessage for triggering LLM reasoning:
import { useState } from "react";
import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";
export default function TodoWidget() {
const { props, isPending, state, setState, sendFollowUpMessage } = useWidget();
const { callTool: toggleTodo } = useCallTool("toggle-todo");
if (isPending) {
return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;
}
const tasks = state?.tasks || props.tasks || [];
const remaining = tasks.filter(t => !t.completed).length;
return (
<McpUseProvider autoSize>
<div style={{ padding: 20 }}>
{tasks.map(t => (
<div key={t.id} style={{ display: "flex", gap: 8, padding: 8 }}>
<input
type="checkbox"
checked={t.completed}
onChange={() => toggleTodo({ id: t.id, completed: !t.completed })}
/>
{t.title}
</div>
))}
<button
onClick={() => sendFollowUpMessage(
`I have ${remaining} tasks left. Help me prioritize them.`
)}
style={{ marginTop: 16, padding: "8px 16px" }}
>
Ask AI to Prioritize
</button>
</div>
</McpUseProvider>
);
}Complete Example
import { useState } from "react";
import { McpUseProvider, useWidget, useCallTool, type WidgetMetadata } from "mcp-use/react";
import { z } from "zod";
const propsSchema = z.object({
todos: z.array(z.object({
id: z.string(),
title: z.string(),
completed: z.boolean()
}))
});
type Props = z.infer<typeof propsSchema>;
export const widgetMetadata: WidgetMetadata = {
description: "Interactive todo list",
props: propsSchema,
exposeAsTool: false
};
export default function InteractiveTodoList() {
const { props, isPending: isLoading } = useWidget<Props>();
const { callTool: createTodo, isPending: isCreating } = useCallTool("create-todo");
const { callTool: toggleTodo } = useCallTool("toggle-todo");
const { callTool: deleteTodo } = useCallTool("delete-todo");
const [newTodo, setNewTodo] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
if (isLoading) {
return <McpUseProvider autoSize><div>Loading todos...</div></McpUseProvider>;
}
const handleCreate = (e: React.FormEvent) => {
e.preventDefault();
if (!newTodo.trim()) return;
createTodo({ title: newTodo }, {
onSuccess: () => setNewTodo(""),
onError: () => alert("Failed to create todo"),
});
};
const handleToggle = (id: string, completed: boolean) => {
toggleTodo({ id, completed: !completed });
};
const handleDelete = (id: string) => {
setDeletingId(id);
deleteTodo({ id }, {
onError: () => alert("Failed to delete"),
onSettled: () => setDeletingId(null),
});
};
return (
<McpUseProvider autoSize>
<div style={{ padding: 20 }}>
<h2>Todos ({props.todos.length})</h2>
<form onSubmit={handleCreate} style={{ marginBottom: 16 }}>
<input
type="text"
value={newTodo}
onChange={e => setNewTodo(e.target.value)}
placeholder="New todo..."
disabled={isCreating}
style={{ padding: 8, width: 300, marginRight: 8 }}
/>
<button type="submit" disabled={isCreating}>
{isCreating ? "Adding..." : "Add"}
</button>
</form>
<div>
{props.todos.map(todo => (
<div
key={todo.id}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: 8, borderBottom: "1px solid #eee"
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id, todo.completed)}
/>
<span style={{
flex: 1,
textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? "#999" : "inherit"
}}>
{todo.title}
</span>
<button
onClick={() => handleDelete(todo.id)}
disabled={deletingId === todo.id}
style={{ color: "red" }}
>
{deletingId === todo.id ? "Deleting..." : "Delete"}
</button>
</div>
))}
</div>
{props.todos.length === 0 && (
<p style={{ color: "#999", textAlign: "center" }}>No todos yet</p>
)}
</div>
</McpUseProvider>
);
}Best Practices
- Use
useCallToolfor built-in state management - No need for manualisPending/errorstate - Declare hooks at the top level - One hook per tool name; React rules apply
- Use
callToolfor fire-and-forget - Handle success/error via callbacks - Use
callToolAsyncfor sequential operations - When you need to await results or chain calls - Use
isError/errorfrom the hook - Instead of manual error state for single-tool widgets - Optimistic updates - Update local state before the call, revert on error
- Confirm destructive actions - Use confirm() for deletes
- Use
sendFollowUpMessagefor LLM reasoning - When you want the AI to analyze, compare, or respond based on widget context rather than mutating data
Next Steps
- Style widgets → ui-guidelines.md
- Advanced patterns → advanced.md
- See examples → ../patterns/common-patterns.md