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.
Sign in to save downloads to your library and vote.
Preview
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
Ingestion metadata
- Source catalog
- awesome-llm-apps
- Repository
- Shubhamsaboo/awesome-llm-apps · main
- File path
- generative_ui_agents/ai-mcp-app-builder/apps/mcp-use-server/.agent/skills/mcp-apps-builder/references/widgets/interactivity.md
- Last refreshed
- 7/23/2026, 10:39:09 PM (4h ago)
- Refresh schedule
- Daily · 03:00 UTC
- Dedupe status
- Unique · deduped by (source, url)