Skip to content

Commit ee3f393

Browse files
committed
docs: add React Native & Expo guide + toJSONResponse / fetchJSON references
Serves three personas: Expo/RN builders hitting streaming-response crashes, builders on other non-streaming runtimes (edge proxies, legacy serverless), and evaluators checking whether TanStack AI supports RN/Expo. - New journey page at docs/chat/non-streaming-runtimes.md titled 'React Native & Expo'. A → B: Expo API route crashing on streaming response → working chat via toJSONResponse + fetchJSON. - Cross-linked from chat/streaming.md (callout near toServerSentEventsResponse) and chat/connection-adapters.md (new 'JSON Array (non-streaming runtimes)' subsection). - Added the new entries to the API references: toJSONResponse in docs/api/ai.md and fetchJSON in docs/api/ai-client.md, each pointing back to the walkthrough. - Registered the new page in docs/config.json under 'Chat & Streaming', sequenced right after Connection Adapters.
1 parent 1bbc932 commit ee3f393

6 files changed

Lines changed: 158 additions & 0 deletions

File tree

docs/api/ai-client.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ import { fetchHttpStream } from "@tanstack/ai-client";
166166
const adapter = fetchHttpStream("/api/chat");
167167
```
168168

169+
### `fetchJSON(url, options?)`
170+
171+
Creates a connection adapter for non-streaming runtimes — pair with [`toJSONResponse`](./ai#tojsonresponsestream-init) on the server. The adapter POSTs `{ messages, data }`, expects a `StreamChunk[]` JSON body, and replays each chunk into the normal `ChatClient` pipeline.
172+
173+
```typescript
174+
import { fetchJSON } from "@tanstack/ai-client";
175+
176+
const adapter = fetchJSON("/api/chat", {
177+
headers: {
178+
Authorization: "Bearer token",
179+
},
180+
});
181+
```
182+
183+
Use this on Expo / React Native / edge proxies that can't emit `ReadableStream` responses. Trade-off: no incremental rendering — the UI sees every chunk at once when the request resolves. Full walkthrough: [React Native & Expo](../chat/non-streaming-runtimes).
184+
169185
### `stream(connectFn)`
170186

171187
Creates a custom connection adapter.

docs/api/ai.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,32 @@ return toServerSentEventsResponse(stream);
191191

192192
A `Response` object suitable for HTTP endpoints with SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`).
193193

194+
## `toJSONResponse(stream, init?)`
195+
196+
Drains the whole stream, then returns a JSON-array `Response` containing every `StreamChunk`. For runtimes that can't emit `ReadableStream` bodies (Expo's `@expo/server`, some edge proxies). Pair with [`fetchJSON`](./ai-client#fetchjsonurl-options) on the client.
197+
198+
```typescript
199+
import { chat, toJSONResponse } from "@tanstack/ai";
200+
import { openaiText } from "@tanstack/ai-openai";
201+
202+
const stream = chat({
203+
adapter: openaiText("gpt-5.2"),
204+
messages: [...],
205+
});
206+
return toJSONResponse(stream);
207+
```
208+
209+
### Parameters
210+
211+
- `stream` - Async iterable of `StreamChunk`
212+
- `init?` - Optional ResponseInit options (including `abortController`). Caller-provided headers are preserved; `Content-Type` defaults to `application/json`.
213+
214+
### Returns
215+
216+
A `Promise<Response>` with the stringified `StreamChunk[]` as the body. If the upstream stream throws mid-drain, a provided `abortController` is aborted and the error propagates.
217+
218+
> **Trade-off:** no incremental rendering — the UI sees every chunk at once when the request resolves. Use SSE / HTTP-stream responses when the runtime supports them. See [React Native & Expo](../chat/non-streaming-runtimes) for the full walkthrough.
219+
194220
## `maxIterations(count)`
195221

196222
Creates an agent loop strategy that limits iterations.

docs/chat/connection-adapters.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ const { messages } = useChat({
8181
});
8282
```
8383

84+
### JSON Array (non-streaming runtimes)
85+
86+
For runtimes that can't emit `ReadableStream` responses — Expo / React Native, some edge proxies, certain legacy serverless runtimes — pair `fetchJSON` on the client with [`toJSONResponse`](../api/ai#tojsonresponsestream-init) on the server:
87+
88+
```typescript
89+
import { useChat } from "@tanstack/ai-react";
90+
import { fetchJSON } from "@tanstack/ai-client";
91+
92+
const { messages } = useChat({
93+
connection: fetchJSON("/api/chat"),
94+
});
95+
```
96+
97+
The server drains the whole chat stream before responding, and this adapter replays each chunk into the normal `ChatClient` pipeline. Trade-off: no incremental rendering — the UI sees every chunk at once when the request resolves. See [React Native & Expo](./non-streaming-runtimes) for the full walkthrough.
98+
8499
## Custom Adapters
85100

86101
For specialized use cases, you can create custom adapters to meet specific protocols or requirements:
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
title: React Native & Expo
3+
id: non-streaming-runtimes
4+
order: 4
5+
description: "Run TanStack AI on React Native, Expo, and other runtimes that can't emit ReadableStream responses — using toJSONResponse on the server and fetchJSON on the client."
6+
keywords:
7+
- tanstack ai
8+
- react native
9+
- expo
10+
- expo router
11+
- metro bundler
12+
- non-streaming
13+
- toJSONResponse
14+
- fetchJSON
15+
- edge runtime
16+
---
17+
18+
You have a React Native or Expo app and you want to add AI chat, but the usual `toServerSentEventsResponse()` helper crashes on Expo's server runtime with:
19+
20+
```
21+
TypeError: Cannot read properties of undefined (reading 'statusText')
22+
```
23+
24+
…and Metro refuses to resolve `@tanstack/ai/adapters` at all. By the end of this guide, you'll have a working chat flow on Expo/React Native using a JSON-array fallback path. The same approach works for any deployment target that can't stream `ReadableStream` responses (some edge proxies, legacy serverless runtimes, etc.).
25+
26+
## What's actually going wrong
27+
28+
Two separate problems show up on React Native / Expo:
29+
30+
1. **Module resolution.** `@tanstack/ai` and `@tanstack/ai-client` ship dual ESM + CJS builds with `main`/`module`/`exports` all wired up. If your version is new enough, Metro resolves them out of the box. If you're stuck on an older version, upgrade — older releases were ESM-only and Metro can't consume them.
31+
32+
2. **Response shape.** Expo's `@expo/server` runtime (and a few edge proxies) can't emit a `ReadableStream` body, which is what `toServerSentEventsResponse` and `toHttpResponse` return. The request silently fails on the client side and `isLoading` flips back to `false` immediately.
33+
34+
The fix for (2) is to drain the chat stream on the server, send the collected chunks as a single JSON array, and replay them on the client. You lose incremental rendering — the UI sees every chunk at once when the request resolves — but every other piece of the chat pipeline keeps working as-is.
35+
36+
## Step 1: Return a JSON-array response on the server
37+
38+
Swap `toServerSentEventsResponse` for `toJSONResponse` in your API route. On Expo Router:
39+
40+
```typescript
41+
// app/api/chat+api.ts
42+
import { chat, toJSONResponse } from "@tanstack/ai";
43+
import { openaiText } from "@tanstack/ai-openai";
44+
45+
export async function POST(request: Request) {
46+
const { messages } = await request.json();
47+
48+
const stream = chat({
49+
adapter: openaiText("gpt-5.2"),
50+
messages,
51+
});
52+
53+
return toJSONResponse(stream);
54+
}
55+
```
56+
57+
`toJSONResponse` iterates the whole stream, collects each `StreamChunk` into an array, and returns a plain `Response` with `Content-Type: application/json`. It accepts the same `init` options as `toServerSentEventsResponse` (including `abortController`) and honours any `Content-Type` you pass in `headers`.
58+
59+
## Step 2: Use `fetchJSON` as the connection adapter on the client
60+
61+
Swap `fetchServerSentEvents` for `fetchJSON` in your `useChat` call:
62+
63+
```typescript
64+
import { useChat } from "@tanstack/ai-react";
65+
import { fetchJSON } from "@tanstack/ai-client";
66+
67+
export function ChatScreen() {
68+
const { messages, sendMessage, isLoading } = useChat({
69+
connection: fetchJSON("/api/chat"),
70+
});
71+
72+
// messages and isLoading behave identically to the streaming path —
73+
// they just update all at once when the request resolves.
74+
return <ChatUI messages={messages} onSend={sendMessage} busy={isLoading} />;
75+
}
76+
```
77+
78+
`fetchJSON` accepts the same `url` + `options` signature as the other connection adapters (static string or function, headers, credentials, custom `fetchClient`, extra body, abort signal). It POSTs the usual `{ messages, data }` body, decodes the response as a `StreamChunk[]`, and replays each chunk into the normal `ChatClient` pipeline — tool calls, approvals, thinking content, errors all behave the same way they do with SSE.
79+
80+
## Step 3: Expect no incremental rendering
81+
82+
The one thing you give up: the UI won't update character-by-character. The request hangs until the server finishes the whole run, then the full message — including tool calls, results, and the final assistant turn — appears at once.
83+
84+
If this becomes a problem, the answer is to move to a runtime that supports streaming responses (Hono on Node, Next.js, TanStack Start, a real SSE endpoint proxied through a CDN that doesn't buffer) rather than to work around the limitation further. The JSON-array path is a pragmatic escape hatch, not the intended happy path.
85+
86+
## Going back to streaming when you can
87+
88+
If you later deploy your server code to a runtime that *does* support streaming, you only need to change two call sites — `toJSONResponse``toServerSentEventsResponse` and `fetchJSON``fetchServerSentEvents`. Everything downstream (messages, tool calls, approvals, `useChat` state, error handling) is identical between the two paths, so there's no cleanup to chase through the app.
89+
90+
## Next Steps
91+
92+
- [Streaming](./streaming) — the normal incremental-rendering path
93+
- [Connection Adapters](./connection-adapters) — full list of client-side adapters, including `fetchJSON`
94+
- [API Reference: `toJSONResponse`](../api/ai#tojsonresponsestream-init) — server-side helper reference
95+
- [API Reference: `fetchJSON`](../api/ai-client#fetchjsonurl-options) — client-side adapter reference

docs/chat/streaming.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export async function POST(request: Request) {
5555
}
5656
```
5757

58+
> **Running on Expo, React Native, or another runtime that can't emit `ReadableStream` responses?** See [React Native & Expo](./non-streaming-runtimes) for the `toJSONResponse` + `fetchJSON` fallback pair.
59+
5860
## Client-Side Streaming
5961

6062
The `useChat` hook automatically handles streaming:

docs/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@
9292
"label": "Connection Adapters",
9393
"to": "chat/connection-adapters"
9494
},
95+
{
96+
"label": "React Native & Expo",
97+
"to": "chat/non-streaming-runtimes"
98+
},
9599
{
96100
"label": "Structured Outputs",
97101
"to": "chat/structured-outputs"

0 commit comments

Comments
 (0)