-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
examples: Add a new TanStack Start example for typed readable streams #5363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f55f458
80c96c1
b1670fd
ff39205
41af48d
bc120b4
1852213
6971dfa
b5fd9ba
15b3d3a
c80eceb
e884da1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
--- | ||
title: Streaming Data from Server Functions | ||
--- | ||
|
||
Streaming data from the server has become very popular thanks to the rise of AI apps. Luckily, it's a pretty easy task with TanStack Start, and what's even better: the streamed data is typed! | ||
|
||
The two most popular ways of streaming data from server functions are using `ReadableStream`-s or async generators. | ||
|
||
You can see how to implement both in the [Streaming Data From Server Functions example](https://github.com/TanStack/router/tree/main/examples/react/start-streaming-data-from-server-functions). | ||
|
||
## Typed Readable Streams | ||
|
||
Here's an example for a server function that streams an array of messages to the client in a type-safe manner: | ||
|
||
```ts | ||
type Message = { | ||
content: string | ||
} | ||
|
||
/** | ||
This server function returns a `ReadableStream` | ||
that streams `Message` chunks to the client. | ||
*/ | ||
const streamingResponseFn = createServerFn().handler(async () => { | ||
// These are the messages that you want to send as chunks to the client | ||
const messages: Message[] = generateMessages() | ||
|
||
// This `ReadableStream` is typed, so each | ||
// will be of type `Message`. | ||
const stream = new ReadableStream<Message>({ | ||
async start(controller) { | ||
for (const message of messages) { | ||
// Send the message | ||
controller.enqueue(message) | ||
} | ||
controller.close() | ||
}, | ||
}) | ||
|
||
return stream | ||
}) | ||
``` | ||
|
||
When you consume this stream from the client, the streamed chunks will be properly typed: | ||
|
||
```ts | ||
const [message, setMessage] = useState('') | ||
|
||
const getTypedReadableStreamResponse = useCallback(async () => { | ||
const response = await streamingResponseFn() | ||
|
||
if (!response) { | ||
return | ||
} | ||
|
||
const reader = response.getReader() | ||
let done = false | ||
while (!done) { | ||
const { value, done: doneReading } = await reader.read() | ||
done = doneReading | ||
if (value) { | ||
// Notice how we know the value of `chunk` (`Message | undefined`) | ||
// here, because it's coming from the typed `ReadableStream` | ||
const chunk = value.content | ||
setMessage((prev) => prev + chunk) | ||
} | ||
} | ||
}, []) | ||
``` | ||
|
||
## Async Generators in Server Functions | ||
|
||
A much cleaner approach with the same results is to use an async generator function: | ||
|
||
```ts | ||
const streamingWithAnAsyncGeneratorFn = createServerFn().handler( | ||
async function* () { | ||
const messages: Message[] = generateMessages() | ||
for (const msg of messages) { | ||
await sleep(500) | ||
// The streamed chunks are still typed as `Message` | ||
yield msg | ||
} | ||
}, | ||
) | ||
``` | ||
|
||
The client side code will also be leaner: | ||
|
||
```ts | ||
const getResponseFromTheAsyncGenerator = useCallback(async () => { | ||
for await (const msg of await streamingWithAnAsyncGeneratorFn()) { | ||
const chunk = msg.content | ||
setMessages((prev) => prev + chunk) | ||
} | ||
}, []) | ||
``` |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,28 @@ | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
"name": "tanstack-start-streaming-data-from-server-functions", | ||||||||||||||||||||||||||||||||
"private": true, | ||||||||||||||||||||||||||||||||
"sideEffects": false, | ||||||||||||||||||||||||||||||||
"type": "module", | ||||||||||||||||||||||||||||||||
"scripts": { | ||||||||||||||||||||||||||||||||
"dev": "vite dev", | ||||||||||||||||||||||||||||||||
"build": "vite build && tsc --noEmit", | ||||||||||||||||||||||||||||||||
"start": "vite start" | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
"dependencies": { | ||||||||||||||||||||||||||||||||
"@tanstack/react-router": "^1.132.33", | ||||||||||||||||||||||||||||||||
"@tanstack/react-router-devtools": "^1.132.33", | ||||||||||||||||||||||||||||||||
"@tanstack/react-start": "^1.132.36", | ||||||||||||||||||||||||||||||||
"react": "^19.0.0", | ||||||||||||||||||||||||||||||||
"react-dom": "^19.0.0", | ||||||||||||||||||||||||||||||||
"zod": "^3.24.2" | ||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||
Comment on lines
+12
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Use workspace protocol for internal TanStack deps Per the repository guidelines, internal TanStack packages in examples should reference local workspaces to avoid mismatched registry installs. - "@tanstack/react-router": "^1.132.33",
- "@tanstack/react-router-devtools": "^1.132.33",
- "@tanstack/react-start": "^1.132.36",
+ "@tanstack/react-router": "workspace:*",
+ "@tanstack/react-router-devtools": "workspace:*",
+ "@tanstack/react-start": "workspace:*", As per coding guidelines. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @coderabbitai all our examples use the versioned packages instead of local references since we need those examples to be runnable on its own (e.g. when using gitpick) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i mistyped, i meant you @coderabbitai There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @schiller-manuel, thank you for the clarification! I apologize for the incorrect suggestion. You're absolutely right that examples need to use versioned packages to remain runnable as standalone projects (e.g., via gitpick). I have a learning in my knowledge base that suggests using
I want to make sure I get this right for future reviews! 🧠 Learnings used
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep it but add a new learning that examples should use versioned packages instead of workspace protocol? |
||||||||||||||||||||||||||||||||
"devDependencies": { | ||||||||||||||||||||||||||||||||
"@types/node": "^22.5.4", | ||||||||||||||||||||||||||||||||
"@types/react": "^19.0.8", | ||||||||||||||||||||||||||||||||
"@types/react-dom": "^19.0.3", | ||||||||||||||||||||||||||||||||
"@vitejs/plugin-react": "^4.3.4", | ||||||||||||||||||||||||||||||||
"typescript": "^5.7.2", | ||||||||||||||||||||||||||||||||
"vite": "^7.1.7", | ||||||||||||||||||||||||||||||||
"vite-tsconfig-paths": "^5.1.4" | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
/* eslint-disable */ | ||
|
||
// @ts-nocheck | ||
|
||
// noinspection JSUnusedGlobalSymbols | ||
|
||
// This file was automatically generated by TanStack Router. | ||
// You should NOT make any changes in this file as it will be overwritten. | ||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. | ||
|
||
import { Route as rootRouteImport } from './routes/__root' | ||
import { Route as IndexRouteImport } from './routes/index' | ||
|
||
const IndexRoute = IndexRouteImport.update({ | ||
id: '/', | ||
path: '/', | ||
getParentRoute: () => rootRouteImport, | ||
} as any) | ||
|
||
export interface FileRoutesByFullPath { | ||
'/': typeof IndexRoute | ||
} | ||
export interface FileRoutesByTo { | ||
'/': typeof IndexRoute | ||
} | ||
export interface FileRoutesById { | ||
__root__: typeof rootRouteImport | ||
'/': typeof IndexRoute | ||
} | ||
export interface FileRouteTypes { | ||
fileRoutesByFullPath: FileRoutesByFullPath | ||
fullPaths: '/' | ||
fileRoutesByTo: FileRoutesByTo | ||
to: '/' | ||
id: '__root__' | '/' | ||
fileRoutesById: FileRoutesById | ||
} | ||
export interface RootRouteChildren { | ||
IndexRoute: typeof IndexRoute | ||
} | ||
|
||
declare module '@tanstack/react-router' { | ||
interface FileRoutesByPath { | ||
'/': { | ||
id: '/' | ||
path: '/' | ||
fullPath: '/' | ||
preLoaderRoute: typeof IndexRouteImport | ||
parentRoute: typeof rootRouteImport | ||
} | ||
} | ||
} | ||
|
||
const rootRouteChildren: RootRouteChildren = { | ||
IndexRoute: IndexRoute, | ||
} | ||
export const routeTree = rootRouteImport | ||
._addFileChildren(rootRouteChildren) | ||
._addFileTypes<FileRouteTypes>() | ||
|
||
import type { getRouter } from './router.tsx' | ||
import type { createStart } from '@tanstack/react-start' | ||
declare module '@tanstack/react-start' { | ||
interface Register { | ||
ssr: true | ||
router: Awaited<ReturnType<typeof getRouter>> | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,20 @@ | ||||||||
import { createRouter } from '@tanstack/react-router' | ||||||||
import { routeTree } from './routeTree.gen' | ||||||||
|
||||||||
export function getRouter() { | ||||||||
const router = createRouter({ | ||||||||
routeTree, | ||||||||
defaultPreload: 'intent', | ||||||||
defaultErrorComponent: (err) => <p>{err.error.stack}</p>, | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider security implications of displaying raw error stack. Displaying Apply this diff to add a clarifying comment: - defaultErrorComponent: (err) => <p>{err.error.stack}</p>,
+ // Note: In production, avoid displaying raw stack traces to users
+ defaultErrorComponent: (err) => <p>{err.error.stack}</p>, 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @coderabbitai this is just a demo, not a production app, so ignore this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @schiller-manuel, you're absolutely right! I understand this is a demo example, and displaying the raw error stack is perfectly appropriate for educational purposes. I'll make a note of this for future reviews of example code. Thank you for the clarification! ✏️ Learnings added
|
||||||||
defaultNotFoundComponent: () => <p>not found</p>, | ||||||||
scrollRestoration: true, | ||||||||
}) | ||||||||
|
||||||||
return router | ||||||||
} | ||||||||
|
||||||||
declare module '@tanstack/react-router' { | ||||||||
interface Register { | ||||||||
router: ReturnType<typeof getRouter> | ||||||||
} | ||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
/// <reference types="vite/client" /> | ||
import * as React from 'react' | ||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' | ||
import { | ||
HeadContent, | ||
Link, | ||
Outlet, | ||
Scripts, | ||
createRootRoute, | ||
} from '@tanstack/react-router' | ||
import appCss from '~/styles/app.css?url' | ||
|
||
export const Route = createRootRoute({ | ||
head: () => ({ | ||
links: [{ rel: 'stylesheet', href: appCss }], | ||
}), | ||
component: RootComponent, | ||
}) | ||
|
||
function RootComponent() { | ||
return ( | ||
<RootDocument> | ||
<Outlet /> | ||
</RootDocument> | ||
) | ||
} | ||
|
||
function RootDocument({ children }: { children: React.ReactNode }) { | ||
return ( | ||
<html> | ||
<head> | ||
<HeadContent /> | ||
</head> | ||
<body> | ||
{children} | ||
<TanStackRouterDevtools position="bottom-right" /> | ||
<Scripts /> | ||
</body> | ||
</html> | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix the Vite start script
vite start
is not a valid CLI entry point in Vite 7, sonpm run start
will fail. Switch tovite preview
instead.📝 Committable suggestion
🤖 Prompt for AI Agents