Install @tanstack/ai-react. Import the UI factory from @tanstack/ai-react/ui. Call createChatHook({ options, ...components }) once at module scope. This matches Form createFormHook and Table createTableHook: widgets register on the factory, mix onto Part / Interrupt / Input, and automatic dispatch still walks the message list.
Deprecated. Do not install @tanstack/ai-react-ui. That package re-exports this subpath until 1.0.0. See Chat UI packages.
The factory returns useAppChat and useChatContext. Call useAppChat() in the screen to create the instance. Render <chat.AppChat />. Call useChatContext() inside a mapped component when it needs live chat. That value is the instance that chat.AppChat provides.
You supply every visible component. There is no default markup, style, or copy.
The factory needs a toolsComponents entry for every tool name in chatOptions. It also needs an interruptsComponents.generic entry for every interrupt id. generic.fallback is optional.
Want a smaller starting point? The chat UI recipes build one thing at a time, from a plain chat box up to per-request context.
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const json: unknown = await request.json()
if (typeof json !== 'object' || json === null || !('messages' in json)) {
return new Response('Invalid body', { status: 400 })
}
const messages = json.messages
const stream = chat({
adapter: openaiText('gpt-5.6'),
messages: Array.isArray(messages) ? messages : [],
})
return toServerSentEventsResponse(stream)
}import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
import { defineInterrupt, 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 purchaseItem = toolDefinition({
name: 'purchaseItem',
description: 'Buy an item',
needsApproval: true,
inputSchema: z.object({ item: z.string() }),
outputSchema: z.object({ ok: z.boolean() }),
}).client()
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [getWeather, purchaseItem],
interrupts: [choosePlan],
outputSchema: z.object({ answer: z.string() }),
}
const { useAppChat, useChatContext } = createChatHook({
options: chatOptions,
components: {
layout: function Layout({ Messages, Interrupts, Input, Queue }) {
const chat = useChatContext()
if (chat.error) return <p>{chat.error.message}</p>
if (chat.isLoading && chat.messages.length === 0) return <p>Loading</p>
if (chat.messages.length === 0) return <p>Empty</p>
return (
<main>
<Messages />
<Interrupts />
<Queue />
<Input />
</main>
)
},
message: function Message({ message, Parts }) {
return <article data-role={message.role}><Parts /></article>
},
input: function Input() {
const chat = useChatContext()
return (
<form
onSubmit={(event) => {
event.preventDefault()
const form = event.currentTarget
const field = form.elements.namedItem('message')
if (!(field instanceof HTMLInputElement)) return
void chat.sendMessage(field.value)
field.value = ''
}}
>
<input name="message" />
<button type="submit">Send</button>
</form>
)
},
queue: function QueueItem({ item }) {
const label = typeof item.content === 'string' ? item.content : 'Queued'
return (
<div>
{label}
<button type="button" onClick={() => item.cancelQueued()}>
Cancel
</button>
</div>
)
},
},
partsComponents: {
text: ({ part }) => <p>{part.content}</p>,
structuredOutput: ({ part }) => <pre>{part.raw}</pre>,
toolResult: ({ part }) => <em>{String(part.content)}</em>,
fallback: ({ part }) => <span>{part.type}</span>,
},
toolsComponents: {
getWeather: ({ part, result }) => {
if (part.state === 'awaiting-input') return <p>Waiting</p>
if (part.state === 'input-streaming') return <p>Streaming input</p>
if (part.state === 'input-complete') return <p>{part.input?.city}</p>
if (part.state === 'approval-requested') return <p>Need approval</p>
if (part.state === 'approval-responded') return <p>Responded</p>
if (part.state === 'error') return <p>Error</p>
return (
<p>
{part.input?.city}: {String(part.output?.temperature ?? result?.content)}
</p>
)
},
purchaseItem: ({ part, interrupt }) => (
<div>
{part.input?.item}
{interrupt?.status === 'pending' ? (
<button onClick={() => interrupt.resolveInterrupt(true)}>
Approve
</button>
) : null}
</div>
),
},
interruptsComponents: {
generic: {
choosePlan: ({ interrupt }) => (
<button onClick={() => interrupt.resolveInterrupt('approved')}>
{interrupt.payload?.title ?? 'Choose plan'}
</button>
),
fallback: ({ interrupt }) => <p>{interrupt.reason}</p>,
},
},
})
export function Support() {
const chat = useAppChat({ threadId: 'support-1' })
return <chat.AppChat />
}
export function Sidebar() {
const chat = useAppChat({ threadId: 'sidebar' })
return <chat.AppChat />
}<Queue /> maps pending sends. Register queue on components. Each item has cancelQueued() bound, so you do not pass the id. Type a queue item with QueueProps<typeof chatOptions>.
layout receives Messages, Interrupts, Queue, and Input as components. Render them as <Messages />, not as calls. Input is only on the props when the config registers an input, so rendering an input you never registered is a compile error instead of a silent no-op.
List input before layout. When input is an inline function expression written after layout, TypeScript cannot yet tell that an input is registered, and Input goes missing from the layout props. Putting input first fixes it; so does using an arrow or a named reference. If it does slip through, rendering <Input /> without a registered input warns once in development rather than failing silently.
A tool map grows fast. Move a tool into its own file and type the props with ToolProps.
ToolProps takes your chatOptions type and the tool name. Then part.input and part.output stay exact.
Part components work the same way. PartProps<typeof chatOptions, 'text'> already has a text part. You do not check part.type. Use 'structuredOutput', 'thinking', 'toolResult', and the other keys from the partsComponents map. fallback still sees every part type.
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook, type PartProps, 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,
result,
}: ToolProps<typeof chatOptions, 'getWeather'>) {
if (part.state === 'awaiting-input') return <p>Waiting</p>
if (part.state === 'input-streaming') return <p>Streaming input</p>
if (part.state === 'error') return <p>Error</p>
return (
<p>
{part.input?.city}: {String(part.output?.temperature ?? result?.content)}
</p>
)
}
export function TextPart({ part }: PartProps<typeof chatOptions, 'text'>) {
return <p>{part.content}</p>
}
export const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages }) => <Messages />,
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: { text: TextPart, fallback: () => null },
toolsComponents: { getWeather: WeatherTool },
})For an interrupt, use InterruptProps. Pass a tool name or a registered interrupt id as the second type argument. Then you do not check interrupt.kind.
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook, type InterruptProps } from '@tanstack/ai-react/ui'
import { defineInterrupt } from '@tanstack/ai'
import { z } from 'zod'
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
interrupts: [choosePlan],
}
export function ChoosePlan({
interrupt,
}: InterruptProps<typeof chatOptions, 'choosePlan'>) {
return (
<button onClick={() => interrupt.resolveInterrupt('approved')}>
{interrupt.payload?.title ?? 'Choose plan'}
</button>
)
}
export const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Interrupts }) => <Interrupts />,
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: { fallback: () => null },
interruptsComponents: {
generic: {
choosePlan: ChoosePlan,
},
},
})Other prop types from the same package:
Mapped components do not receive chat as a prop. Call useChatContext() inside a component when it needs live chat. That call opts the component into chat re-renders. Nested children can call it too. Widgets in other files should call createChatHookContexts() first so they do not import the factory result (circular import).
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
function StatusLine() {
const chat = useChatContext()
if (chat.error) return <p>{chat.error.message}</p>
if (chat.isLoading) return <p>Loading</p>
return <p>{chat.messages.length} messages</p>
}
const { useAppChat, useChatContext } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages }) => (
<main>
<StatusLine />
<Messages />
</main>
),
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: { fallback: () => null },
})
export function ChatScreen() {
const chat = useAppChat()
return <chat.AppChat />
}Call useChatContext() only inside AppChat or Provider. A call outside that tree throws.
useAppChat from createChatHook owns the state. useChatContext() only reads the instance that chat.AppChat provides.
When a widget lives in another file, call createChatHookContexts() first and pass chatContext, partContext, and interruptContext under context. Then that file can import useChatContext from the contexts module.
Part and interrupt widgets take part and interrupt as props. Type them with PartProps, ToolProps, or InterruptProps. Do not read those values from context.
A tool with needsApproval: true can render its approval in two places.
Read interrupt on the tool. Render the approval in that same component. Do not register interrupts.tools for that name. A mapped tool keeps its approval off the list.
interrupt is already the approval for that tool name. You do not check interrupt.kind.
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 purchaseItem = toolDefinition({
name: 'purchaseItem',
description: 'Buy an item',
needsApproval: true,
inputSchema: z.object({ item: z.string() }),
outputSchema: z.object({ ok: z.boolean() }),
}).client()
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [purchaseItem],
}
function PurchaseItem({
part,
interrupt,
}: ToolProps<typeof chatOptions, 'purchaseItem'>) {
return (
<div>
{part.input?.item}
{interrupt?.status === 'pending' ? (
<button onClick={() => interrupt.resolveInterrupt(true)}>
Approve
</button>
) : null}
</div>
)
}
const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages, Interrupts }) => (
<main>
<Messages />
<Interrupts />
</main>
),
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: { fallback: () => null },
toolsComponents: {
purchaseItem: PurchaseItem,
},
})
export function InlineApprovalChat() {
const chat = useAppChat()
return <chat.AppChat />
}To split the approval into its own file, type it with InterruptProps<typeof chatOptions, 'purchaseItem'>. Render that component from the tool.
Register the approval under interrupts.tools. That component appears in the interrupt list. Do not also render interrupt on the tool unless you want it in both places.
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const purchaseItem = toolDefinition({
name: 'purchaseItem',
description: 'Buy an item',
needsApproval: true,
inputSchema: z.object({ item: z.string() }),
outputSchema: z.object({ ok: z.boolean() }),
}).client()
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [purchaseItem],
}
const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Messages, Interrupts }) => (
<main>
<Messages />
<Interrupts />
</main>
),
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: { fallback: () => null },
toolsComponents: {
purchaseItem: ({ part }) => <div>{part.input?.item}</div>,
},
interruptsComponents: {
tools: {
purchaseItem: ({ interrupt }) => (
<button onClick={() => interrupt.resolveInterrupt(true)}>
Approve
</button>
),
},
},
})
export function ListApprovalChat() {
const chat = useAppChat()
return <chat.AppChat />
}Generic interrupts always render in the list (<Interrupts /> / <UI.Interrupts>). They never render inside a tool.
Map them under interruptsComponents.generic:
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'
import { defineInterrupt } from '@tanstack/ai'
import { z } from 'zod'
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
interrupts: [choosePlan],
}
const { useAppChat } = createChatHook({
options: chatOptions,
components: {
layout: ({ Interrupts }) => <Interrupts />,
message: ({ Parts }) => <article><Parts /></article>,
},
partsComponents: { fallback: () => null },
interruptsComponents: {
generic: {
choosePlan: ({ interrupt }) => (
<button onClick={() => interrupt.resolveInterrupt('approved')}>
{interrupt.payload?.title ?? 'Choose plan'}
</button>
),
fallback: ({ interrupt }) =>
interrupt.kind === 'unbound' ? (
<p>Paused elsewhere: {interrupt.reason}</p>
) : (
<p>{interrupt.reason}</p>
),
},
},
})
export function GenericInterruptChat() {
const chat = useAppChat()
return <chat.AppChat />
}You can mix this map with interrupts.tools in the same factory call.
createChatUI is the lower-level kit. Use it when you walk messages yourself. Screens still call createChatHook.
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)
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>
)
}Unknown runtime tool names warn once in development and render nothing. Add a parts.fallback for unknown part types.
Automatic dispatch is the default. You can also pick a registered widget at the call site, like Form field.TextField and Table cell.TextCell:
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>
)
}p.getWeather and p.text are the widgets you passed to the factory. p.Render walks this one part the automatic way. UI.Input is mixed onto the kit when you register input.
If widgets live in other files, call createChatHookContexts() first and pass chatContext, partContext, and interruptContext under context. That breaks the circular import, the same way Form uses createFormHookContexts.
See also Solid, Vue, Svelte, and custom adapters.