useRenderTool
Register a React Native component to render inline when an agent calls a tool.
Overview
useRenderTool is the React Native counterpart of the web tool-rendering hooks. It registers a tool with the agent and a render function that produces a React Native element for that tool's call, so the agent can drive inline generative UI inside the chat. It combines useFrontendTool (registration + optional handler) with a render registry that the prebuilt CopilotChat reads when displaying tool calls.
useRenderToolCall is exported on React Native โ import it from
@copilotkit/react-native to render a registered tool call on any surface,
not just inside the chat (see Rendering a tool call outside the
chat). The remaining web rendering
hooks (useDefaultRenderTool, useRenderActivityMessage,
useRenderCustomMessages) are not exported on React Native, because they
render host DOM or link the web chat-message stack.
Signature
import { useRenderTool } from "@copilotkit/react-native";
function useRenderTool<
T extends Record<string, unknown> = Record<string, unknown>,
>(options: UseRenderToolOptions<T>, deps?: ReadonlyArray<unknown>): void;T is the parsed shape of the tool's arguments. It is constrained to
Record<string, unknown> and defaults to it, so calling the hook without an
explicit type argument is valid โ T is normally inferred from the parameters
schema.
Parameters
Prop
Type
Prop
Type
RenderToolProps<T>
Props passed to your render function. The type is not declared in the React
Native package โ it is derived from react-core's canonical renderer contract, so
React Native and web cannot drift apart:
type RenderToolProps<T = Record<string, unknown>> = React.ComponentProps<
ReactToolCallRenderer<T>["render"]
>;That resolves to a three-arm union discriminated on status: args is
Partial<T> on the in-progress arm and T on the executing and complete arms,
and result is a string only on the complete arm (undefined on the other
two). Narrow on status before reading args fields or result.
The discriminant is the ToolCallStatus enum โ not a string-literal union.
Each arm is typed ToolCallStatus.InProgress, ToolCallStatus.Executing or
ToolCallStatus.Complete. Comparing a status against the string a member
carries still type-checks and still narrows, so status === "executing" is
valid TypeScript; the enum members are the recommended style rather than a
compilation requirement, because they say which arm you mean and break loudly if
a member's value ever changes. Two things are genuinely errors: comparing
against a string that matches no member (TS2367, "no overlap"), and assigning a
bare "executing" to a status-typed variable (TS2322 โ a raw string is not
assignable to the enum, even though the enum is comparable to it).
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
RenderToolInProgressProps, RenderToolExecutingProps, RenderToolCompleteProps
@copilotkit/react-native re-exports these three types from react-core. They are
not arms of the RenderToolProps<T> above and they do not describe the props
this hook passes to render:
- they are generic over a schema (
S extends StandardSchemaV1), not over the parsed argument objectT; - they carry arguments under
parameters(Partial<InferSchemaOutput<S>>, thenInferSchemaOutput<S>), where React Native's render props useargs; - they declare
statusas the string literals"inProgress"/"executing"/"complete", where React Native'sstatusis theToolCallStatusenum; - they are the arms of react-core's own
RenderToolProps<S>union, which belongs to the webuseRenderToolโ a different hook from the React Native one documented here, and one React Native does not export.
Type React Native render functions with RenderToolProps<T> or
RenderToolFunction<T> (both exported from @copilotkit/react-native). Reach for
the three RenderTool*Props types only when sharing code with a web react-core
renderer.
Usage
import { ToolCallStatus, useRenderTool } from "@copilotkit/react-native";
import { CopilotChat } from "@copilotkit/react-native/components";
import { ActivityIndicator, Text, View } from "react-native";
import { z } from "zod";
function ChatScreen() {
useRenderTool({
name: "showWeather",
description: "Display weather for a city",
parameters: z.object({
city: z.string(),
temp: z.number(),
condition: z.string(),
}),
render: ({ args, status }) => (
<View style={{ padding: 12, backgroundColor: "#f0f0f0", borderRadius: 8 }}>
<Text style={{ fontWeight: "bold" }}>{args.city}</Text>
<Text>{args.temp}ยฐC ยท {args.condition}</Text>
{status === ToolCallStatus.Executing && <ActivityIndicator />}
</View>
),
});
return <CopilotChat agentName="default" />;
}Import CopilotChat from the /components subpath. That is the prebuilt UI
that reads the render registry and paints tool calls inline; the CopilotChat
exported from the @copilotkit/react-native root is
headless and renders no message
list, so a registered render would never appear.
Behavior
- Render returns
ReactElement | null, notReactNode. React Native'sFlatListcannot render bare strings or portals, so a render function must return an element ornull. - Arguments stream. While the agent is still writing the call,
statusisToolCallStatus.InProgressandargsis partial โ fields arrive progressively. Write renderers that tolerate missing fields; that is what lets UI build as the agent writes it. renderis captured at registration. It is not refreshed on every render โ only when the tool re-registers, which happens whendepscompare as changed. This matches the web hooks. If yourrendercloses over component state or props that change over time, list a JSON-comparable form of them indeps, or read them through a ref (see below); otherwise the chat keeps invoking the stale closure and paints outdated UI. Earlier React Native versions refreshed the closure on every render, so code that relied on that must now handle staleness explicitly.- Cleanup: unmounting removes the tool (the agent can no longer call it) but keeps the render function registered, so tool calls already in the chat history still render after you navigate away.
Values deps cannot see
deps is forwarded to useFrontendTool
in react-core, which decides whether to re-register by comparing
JSON.stringify(deps)
against the previous render's. Serialization, not reference identity, is the
comparison โ which has consequences worth knowing before you reach for deps:
- Non-serializable deps are inert. Inside the array, a function or symbol serializes to
null, and aMap, aSet, or a class instance keeping its state in private fields or getters serializes to{}โ the same string on every render, forever. Listing a callback or aMapindepstype-checks, reads like a fix, and re-registers nothing. - Circular values throw.
JSON.stringifyraises aTypeErrorwhile the hook renders, so a dep with a cycle in it crashes the screen instead of failing quietly. - Key order counts. Two plain objects holding the same entries in a different insertion order serialize differently and do re-register, even though nothing meaningful changed.
For a value JSON cannot compare, do not put it in deps โ it will not work.
Either derive a primitive that tracks the change ([selection.size] rather than
[selection]), or keep the value in a ref that render dereferences when it
runs:
function SeatPicker({ onSelect }: { onSelect: (id: string) => void }) {
// Reassigned on every render; the captured `render` reads it at call time.
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
useRenderTool({
name: "pickSeat",
description: "Let the traveler pick a seat",
parameters: z.object({ seats: z.array(z.string()) }),
render: ({ args }) => (
<SeatGrid
seats={args.seats ?? []}
onSelect={(id) => onSelectRef.current(id)}
/>
),
});
}The captured render is still the stale one, but the ref it reads is current, so
the tool never has to re-register to reach the newest callback. That makes the ref
pattern the more reliable default whenever what changes is behavior rather than
displayed data.
Rendering a tool call outside the chat
CopilotChat renders tool calls inline. To render a registered component anywhere else โ a dashboard, a kiosk, a full-screen stage the agent composes โ call useRenderToolCall() inside a component mounted under CopilotKitProvider, and read the tool calls off the agent's own message list:
import { useAgent, useRenderToolCall } from "@copilotkit/react-native";
import { View } from "react-native";
function ToolCallStage({ agentId = "default" }: { agentId?: string }) {
const { agent } = useAgent({ agentId });
const renderToolCall = useRenderToolCall();
const messages = agent.messages ?? [];
// Pair each call with its result message โ the same correlation the prebuilt
// chat does. `toolMessage` is what selects the complete arm: pass it and the
// call resolves to `ToolCallStatus.Complete` with `result`. Without one,
// `result` is `undefined` and the status comes from the provider instead โ
// `ToolCallStatus.Executing` while this call id is one the provider is tracking
// as executing, and `ToolCallStatus.InProgress` otherwise.
const toolMessages = new Map(
messages.flatMap((message) =>
message.role === "tool" ? [[message.toolCallId, message] as const] : [],
),
);
const toolCalls = messages.flatMap((message) =>
message.role === "assistant" ? (message.toolCalls ?? []) : [],
);
// Each returned element is already keyed by tool-call id.
return (
<View>
{toolCalls.map((toolCall) =>
renderToolCall({
toolCall,
toolMessage: toolMessages.get(toolCall.id),
}),
)}
</View>
);
}Prop
Type
RenderToolProvider is removed โ there is no separate registry provider to mount. See the migration section below.
Migrating from useRenderToolRegistry (removed)
useRenderToolRegistry is gone. It exposed a React Native-only registry that no
longer exists โ render functions now live in the same registry the rest of
CopilotKit uses, which is what makes useComponent
work on React Native and keeps chat history rendering after navigation.
- const registry = useRenderToolRegistry();
- const renderer = registry.get(toolCall.function.name);
- return renderer ? renderer({ args, status }) : null;
+ const renderToolCall = useRenderToolCall();
+ return renderToolCall({ toolCall });Render props also change. They gain name and toolCallId, and status widens:
it was the two-member string-literal union "executing" | "complete", and is now
the three-member ToolCallStatus enum (ToolCallStatus.InProgress was added,
for the state where the agent is still streaming the call). The added arm is the
breaking part โ a renderer that handled only "executing" and "complete" now
has a third state to account for. Your existing string comparisons keep working:
status === "complete" still type-checks and still narrows against the enum. So
moving them onto the members is a recommended cleanup, not a required migration
step:
- if (status === "complete") return <Result value={result} />;
+ if (status === ToolCallStatus.Complete) return <Result value={result} />;What does not survive the change is assigning a raw string into a
status-typed variable (const s: RenderToolProps<T>["status"] = "complete" is
TS2322), and comparing against a string that is not one of the three member
values (TS2367).
args used to be the fully-typed T in every state; it is now Partial<T> on
the newly added ToolCallStatus.InProgress arm and stays the full T once
status is ToolCallStatus.Executing or ToolCallStatus.Complete. So renderers
that previously assumed every field was present must now tolerate missing fields
while in progress. result is narrowed the other way: it was string | undefined in every state, and is now undefined unless status is
ToolCallStatus.Complete.
The args half is the one most existing renderers actually trip over, and it
fails at compile time rather than on screen. On the un-narrowed union args
is Partial<T> | T, so args.foo reads as T["foo"] | undefined and a strict
type check (tsc --noEmit โ what check-types runs) rejects every use that needs
the field to be present: dereferencing or calling it is TS18048
('args.foo' is possibly 'undefined'), and passing it into a prop or argument
typed without undefined is TS2322 / TS2345. Interpolating it bare into JSX โ
<Text>{args.city}</Text> โ still compiles, because an element accepts
undefined children. That is why this break surfaces as a type error rather than
as something visibly wrong in the chat.
Narrowing on status clears it, and the later arms hand back the full T:
- render: ({ args }) => <WeatherCard city={args.city} temp={args.temp} />,
+ render: ({ args, status }) => {
+ if (status === ToolCallStatus.InProgress) return <WeatherSkeleton />;
+ return <WeatherCard city={args.city} temp={args.temp} />;
+ },An early return on the in-progress arm is usually the smallest change. Per-field
defaults (args.city ?? "") also satisfy the checker, at the cost of painting a
half-written call as though it had finished.
Two behavior changes ride along with the props change, both documented under
Behavior. render is now captured at registration and refreshed
only when deps compare as changed, so a render that closes over changing state
or props needs those values in deps โ or a ref โ or the chat keeps painting the
stale closure. And unmounting no longer unregisters the render function: the tool
itself is still removed, but tool calls already in the chat history keep rendering
after you navigate away.
Related
useFrontendTool: register a tool without inline renderinguseHumanInTheLoop: gate a tool call on user approval