TanStack

React Example: Streaming Structured Table GitHub

/src/routes/index.tsx
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { OpenRouterKeyForm } from '@/components/open-router-key-form'
import { byok } from '@/lib/byok'
import { tableSchema } from '@/lib/table-schema'

function TablePage() {
  const [input, setInput] = useState('')
  const { sendMessage, isLoading, error, stop, partial, final } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
    byok,
    outputSchema: tableSchema,
  })

  const rows = partial.rows ?? []
  const title = partial.title
  const handleSendMessage = () => {
    if (!input.trim()) return
    sendMessage(input.trim())
    setInput('')
  }

  return (
    <div className="flex h-screen flex-col bg-gray-900">
      <a
        href="#main"
        className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 focus:rounded-lg focus:bg-orange-500 focus:px-3 focus:py-2 focus:text-white"
      >
        Skip to table
      </a>
      <header className="border-b border-orange-500/20 bg-gray-800 px-4 py-3">
        <h1 className="mb-2 text-lg font-semibold text-white">
          Streaming structured table
        </h1>
        <OpenRouterKeyForm />
      </header>

      <main id="main" className="min-h-0 flex-1 overflow-auto px-4 py-4">
        {rows.length === 0 && !isLoading ? (
          <p className="text-sm text-gray-400">
            Paste an OpenRouter key. Then ask for a comparison table.
          </p>
        ) : (
          <table
            className="w-full border-collapse text-left text-sm text-white"
            aria-busy={isLoading}
          >
            <caption className="mb-3 text-left text-base font-semibold text-white">
              {title ?? 'Results'}
              {isLoading ? ' (streaming)' : final ? ' (complete)' : ''}
            </caption>
            <thead>
              <tr className="border-b border-orange-500/30">
                <th scope="col" className="py-2 pr-3 font-medium">
                  Name
                </th>
                <th scope="col" className="py-2 pr-3 font-medium">
                  Year
                </th>
                <th scope="col" className="py-2 pr-3 font-medium">
                  Kind
                </th>
                <th scope="col" className="py-2 font-medium">
                  Note
                </th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row, index) => (
                <tr key={index} className="border-b border-gray-800">
                  <th scope="row" className="py-2 pr-3 font-medium">
                    {row.name ?? '…'}
                  </th>
                  <td className="py-2 pr-3">{row.year ?? '…'}</td>
                  <td className="py-2 pr-3">{row.kind ?? '…'}</td>
                  <td className="py-2">{row.note ?? '…'}</td>
                </tr>
              ))}
            </tbody>
          </table>
        )}

        {error ? (
          <p className="mt-3 text-sm text-red-400">{error.message}</p>
        ) : null}

        <p className="sr-only" aria-live="polite">
          {isLoading ? 'Streaming table rows' : final ? 'Table complete' : ''}
        </p>
      </main>

      <div className="border-t border-orange-500/10 bg-gray-900/80 px-4 py-3">
        {isLoading ? (
          <div className="mb-3 flex justify-center">
            <button
              type="button"
              onClick={stop}
              className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
            >
              Stop
            </button>
          </div>
        ) : null}
        <div className="flex items-end gap-2">
          <label className="sr-only" htmlFor="table-prompt">
            Prompt
          </label>
          <textarea
            id="table-prompt"
            value={input}
            onChange={(event) => setInput(event.target.value)}
            placeholder="Compare 6 JavaScript frameworks"
            className="w-full resize-none rounded-lg border border-orange-500/20 bg-gray-800/50 px-4 py-3 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500/50"
            rows={1}
            disabled={isLoading}
            onKeyDown={(event) => {
              if (event.key === 'Enter' && !event.shiftKey && input.trim()) {
                event.preventDefault()
                handleSendMessage()
              }
            }}
          />
          <button
            type="button"
            onClick={handleSendMessage}
            disabled={!input.trim() || isLoading}
            className="rounded-lg bg-orange-500 px-4 py-3 text-sm font-medium text-white disabled:opacity-50"
          >
            Send
          </button>
        </div>
      </div>
    </div>
  )
}

export const Route = createFileRoute('/')({
  component: TablePage,
})