TanStack
UI

React Chat UI

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.

Server

ts
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)
}

Client

tsx
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.

Type a component in its own file

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.

tsx
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 },
})
  1. Put chatOptions in a shared module.
  2. Import ToolProps or PartProps from @tanstack/ai-react/ui.
  3. Type the component with ToolProps<typeof chatOptions, 'getWeather'> or PartProps<typeof chatOptions, 'text'>.
  4. Pass that component into tools.getWeather or parts.text.

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.

  • A tool approval: InterruptProps<typeof chatOptions, 'purchaseItem'>. Then interrupt.toolName is 'purchaseItem'.
  • A registered generic interrupt: InterruptProps<typeof chatOptions, 'choosePlan'>. Then interrupt.payload and interrupt.resolveInterrupt match the definition.
tsx
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:

  • LayoutProps
  • MessageProps
  • InputProps
  • QueueProps for one queued send. item.cancelQueued() drops that item.
  • PartProps with a part key such as 'text'
  • InterruptProps for tool approvals, registered generic interrupts, and generic.fallback. Pass a tool name or interrupt id as the second type argument.

Read chat from useChatContext()

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).

tsx
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.

Tool approvals: inline or list

A tool with needsApproval: true can render its approval in two places.

Inline, next to the tool

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.

tsx
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.

List, in <Interrupts />

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.

tsx
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

Generic interrupts always render in the list (<Interrupts /> / <UI.Interrupts>). They never render inside a tool.

Map them under interruptsComponents.generic:

  • A registered id such as choosePlan: the component for that definition
  • fallback: every other list interrupt, including an unknown generic id and an unbound interrupt this chat does not own
tsx
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.

Manual traversal

createChatUI is the lower-level kit. Use it when you walk messages yourself. Screens still call createChatHook.

tsx
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:

tsx
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.