0

Widget Interactivity

awesome-llm-appswidgets

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:

PropertyDescription
isPendingTool is executing
isSuccessSucceeded — data is available
isErrorFailed — error is available
isIdleNo call made yet
callToolFire-and-forget; optional onSuccess/onError/onSettled callbacks
callToolAsyncReturns 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

  1. Use useCallTool for built-in state management - No need for manual isPending/error state
  2. Declare hooks at the top level - One hook per tool name; React rules apply
  3. Use callTool for fire-and-forget - Handle success/error via callbacks
  4. Use callToolAsync for sequential operations - When you need to await results or chain calls
  5. Use isError/error from the hook - Instead of manual error state for single-tool widgets
  6. Optimistic updates - Update local state before the call, revert on error
  7. Confirm destructive actions - Use confirm() for deletes
  8. Use sendFollowUpMessage for LLM reasoning - When you want the AI to analyze, compare, or respond based on widget context rather than mutating data

Next Steps

Ingestion metadata

Source catalog
awesome-llm-apps
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)