createChatHook from @tanstack/ai-react/ui binds your chat options and components once. Tool calls and approvals stay typed.
Each screen then renders the chat with one hook call. You supply every visible component. There is no default markup or style.
Note
Upgrading from @tanstack/ai-react-ui? Read Upgrade to 1.0.
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaiimport {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
})
return toServerSentEventsResponse(stream)
}import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages }) => (
<section>
<Messages />
</section>
),
message: ({ message, Parts }) => (
<article data-role={message.role}>
<Parts />
</article>
),
},
partsComponents: {
text: ({ part }) => <p>{part.content}</p>,
fallback: () => null,
},
})
export function ChatScreen() {
const chat = useAppChat({ threadId: 'support-chat' })
return (
<main>
<chat.AppChat />
<form
onSubmit={(event) => {
event.preventDefault()
const field = event.currentTarget.elements.namedItem('message')
if (!(field instanceof HTMLInputElement)) return
const text = field.value.trim()
if (!text) return
field.value = ''
void chat.sendMessage(text)
}}
>
<input name="message" />
<button type="submit">Send</button>
</form>
</main>
)
}The reply streams into the page as the model writes it.
Each step adds one thing to the quick setup. The chat UI recipes have the full file for each one.
Add the tool to tools in chatOptions. Then register a component under the tool name in toolsComponents. part.input and part.output are typed from the tool schemas.
toolsComponents: {
getWeather: ({ part }) => {
if (part.state !== 'complete') return <p>Looking up weather</p>
return (
<p>
{part.input?.city}: {part.output?.temperature}
</p>
)
},
},The factory needs one entry for every tool in chatOptions. Full example: Make a tool look right.
Set needsApproval: true on the tool. The tool component then gets an interrupt prop:
toolsComponents: {
deleteFile: ({ part, interrupt }) =>
interrupt?.status === 'pending' ? (
<button onClick={() => interrupt.resolveInterrupt(true)}>
Delete {part.input?.path}
</button>
) : (
<p>{part.input?.path}</p>
),
},resolveInterrupt(true) approves. resolveInterrupt(false) denies. Full example: Ask before a tool runs.
Define the question with defineInterrupt. Add it to interrupts in chatOptions. Register a component under its id in interruptsComponents.generic, and render Interrupts in the layout:
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
// In createChatHook:
interruptsComponents: {
generic: {
choosePlan: ({ interrupt }) => (
<button onClick={() => interrupt.resolveInterrupt('pro')}>
{interrupt.payload?.title}
</button>
),
fallback: ({ interrupt }) => <p>{interrupt.reason}</p>,
},
},The factory needs one entry for every interrupt id in chatOptions. Full example: Ask the user your own question.
Mapped components do not get chat as a prop. Call useChatContext() inside the widget. This also moves the composer into the layout: register it as input, then render Input.
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
const { useAppChat, useChatContext } = createChatHook({
options: chatOptions,
components: {
input: () => {
const chat = useChatContext()
return (
<form
onSubmit={(event) => {
event.preventDefault()
const field = event.currentTarget.elements.namedItem('message')
if (!(field instanceof HTMLInputElement)) return
const text = field.value.trim()
if (!text) return
field.value = ''
void chat.sendMessage(text)
}}
>
<input name="message" disabled={chat.isLoading} />
<button type="submit">Send</button>
</form>
)
},
layout: ({ Messages, Input }) => (
<main>
<Messages />
<Input />
</main>
),
message: ({ Parts }) => (
<article>
<Parts />
</article>
),
},
partsComponents: {
text: ({ part }) => <p>{part.content}</p>,
fallback: () => null,
},
})
export function ChatScreen() {
const chat = useAppChat({ threadId: 'support-chat' })
return <chat.AppChat />
}Call useChatContext() only inside chat.AppChat. A call outside that tree throws.
Model replies arrive as markdown. Register TextPart from @tanstack/ai-react/ui as the text part:
import { TextPart } from '@tanstack/ai-react/ui'
partsComponents: {
text: ({ part }) => <TextPart content={part.content} />,
fallback: () => null,
},TextPart uses TanStack Markdown. It escapes raw HTML and removes executable URLs. To color fenced code, read Highlight markdown code.
Move a widget to its own file and type its props with ToolProps, PartProps, or InterruptProps. Pass typeof chatOptions and a name:
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook, type ToolProps } from '@tanstack/ai-react/ui'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const getWeather = toolDefinition({
name: 'getWeather',
description: 'Look up weather',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ temperature: z.number() }),
}).client()
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [getWeather],
}
export function WeatherTool({
part,
}: ToolProps<typeof chatOptions, 'getWeather'>) {
if (part.state !== 'complete') return <p>Looking up weather</p>
return (
<p>
{part.input?.city}: {part.output?.temperature}
</p>
)
}
export const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages }) => <Messages />,
message: ({ Parts }) => (
<article>
<Parts />
</article>
),
},
partsComponents: { fallback: () => null },
toolsComponents: { getWeather: WeatherTool },
})Put chatOptions in a shared module so every widget file can import its type.
You now have a typed chat UI in React. Each widget is your own component.
| Config key | Keys | Props |
|---|---|---|
| components | layout, message, input, queue | LayoutProps, MessageProps, InputProps, QueueProps |
| partsComponents | a part key, or fallback | PartProps<typeof chatOptions, 'text'> |
| toolsComponents | each tool name in chatOptions.tools | ToolProps<typeof chatOptions, 'getWeather'> |
| interruptsComponents.tools | an approval tool name | InterruptProps<typeof chatOptions, 'deleteFile'> |
| interruptsComponents.generic | each interrupt id, plus an optional fallback | InterruptProps<typeof chatOptions, 'choosePlan'> |
| subagentsComponents | each subagent name | SubagentProps. See Subagents |
Part keys:
layout gets these components. Render them as tags, such as <Messages />:
useAppChat() takes per-instance options: threadId (required), initialMessages, live, and forwardedProps. Keep tools, interrupts, and outputSchema on chatOptions. Two instances with different threadId values keep separate histories:
const chat = useAppChat({ threadId: 'support-1' })| part.state | Meaning |
|---|---|
| awaiting-input | The call started. No arguments yet. |
| input-streaming | The model is still writing the arguments. |
| input-complete | The arguments are final. |
| approval-requested | The tool waits for approval. |
| approval-responded | The user approved or denied. |
| complete | The result arrived. |
| error | The call failed. |
A tool component also gets result, the matching tool-result part, when one exists.
A tool with needsApproval: true renders its approval in one of two places:
Do not do both for one tool. A list approval component reads interrupt.originalArgs, typed from the tool inputSchema.
Generic interrupts always render in Interrupts. They never render inside a tool. interruptsComponents.generic.fallback renders:
If the copy for an unbound interrupt must be different, branch on interrupt.kind === 'unbound'.
By default, a message sent while a run streams waits in a queue. Register queue on components and render Queue in the layout. Each item has cancelQueued() bound to it:
components: {
layout: ({ Messages, Queue }) => (
<main>
<Messages />
<Queue />
</main>
),
message: ({ Parts }) => <Parts />,
queue: ({ item }) => (
<div>
{typeof item.content === 'string' ? item.content : 'Queued'}
<button type="button" onClick={() => item.cancelQueued()}>
Cancel
</button>
</div>
),
},Type a queue item with QueueProps<typeof chatOptions>.
If input is an inline function expression written after layout, TypeScript cannot see that an input is registered. Then Input is missing from the layout props.
To prevent this, do one of these:
If it still slips through, <Input /> without a registered input warns once in development.
A widget file that imports useChatContext from the factory module makes a circular import. To prevent this, call createChatHookContexts() in its own module first:
// chat-contexts.ts
import { createChatHookContexts } from '@tanstack/ai-react/ui'
export const { chatContext, partContext, interruptContext, useChatContext } =
createChatHookContexts()Pass chatContext, partContext, and interruptContext to createChatHook under context. Widget files then import useChatContext from the contexts module.
Part and interrupt widgets get part and interrupt as props. Do not read those from context.
createChatUI is the lower-level kit. If you map messages yourself, use it. It takes the same component maps:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { createChatUI } from '@tanstack/ai-react/ui'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const getWeather = toolDefinition({
name: 'getWeather',
description: 'Look up weather',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ temperature: z.number() }),
}).client()
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [getWeather],
}
const UI = createChatUI(chatOptions, {
components: {
layout: ({ Messages }) => <Messages />,
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: {
text: ({ part }) => <p>{part.content}</p>,
fallback: () => null,
},
toolsComponents: {
getWeather: ({ part }) => <p>{part.input?.city}</p>,
},
})
export function ManualChat() {
const chat = useChat({ ...chatOptions, threadId: 'support-chat' })
return (
<UI.Provider chat={chat}>
<UI.Messages>
{(messages) =>
messages.map((message) => (
<UI.Message key={message.id} message={message}>
{(parts) =>
parts.map((part, index) => (
<span key={index}>{part.key}</span>
))
}
</UI.Message>
))
}
</UI.Messages>
</UI.Provider>
)
}You can also pick a registered widget at the call site:
import type { MessageProps } from '@tanstack/ai-react/ui'
function PickedWidgets({
message,
}: {
message: MessageProps<typeof chatOptions>['message']
}) {
return (
<UI.Message message={message}>
{(parts) =>
parts.map((part, index) => (
<UI.Part key={index} part={part}>
{(p) => (part.key === 'toolCall' ? <p.getWeather /> : <p.Render />)}
</UI.Part>
))
}
</UI.Message>
)
}An unknown tool name at runtime warns once in development and renders nothing.
import { TextPart } from '@tanstack/ai-react/ui'
export function Reply({ content }: { content: string }) {
return (
<TextPart
content={content}
components={{
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
}}
/>
)
}If you still pass remarkPlugins, rehypePlugins, or disableDefaultPlugins, read TextPart markdown.