react-streaming
React 18 Streaming. Full-fledged & Easy.
Unfamiliar with React Streaming? Check out Dan's article about SSR and Streaming.
Follow: Twitter > @brillout
Chat: Discord > Vikereact-streaming
Contents
Intro
Features (for React users):
- Unlocks
<Suspense>
for SSR apps. - Unlocks React libraries of tomorrow. (Such as using Telefunc for SSR data fetching.)
- Seamless support for Node.js (serverless) platforms (Vercel, AWS EC2, ...) and Edge platforms (Cloudflare Workers, Deno Deploy, Netlify Edge, Vercel Edge, ...).
- Two SEO strategies:
conservative
or google-speed
. - Easy error handling.
- Bonus: new
useAsync()
hook.
Features (for library authors):
useSsrData()
: Define isomorphic data.injectToStream()
: Inject chunks to the stream.
Easy:
import { renderToStream } from 'react-streaming/server'
const {
pipe,
readable
} = await renderToStream(<Page />)
⚠️
While react-streaming
is stable in itself (it's used in production and has good CI test coverage), note that React's SSR streaming support is still early and that the React team is working on high-level APIs that may make parts of react-streaming
obsolete, see React RFC injectToStream - @sebmarkbage comment.
Why Streaming
React 18's new SSR streaming architecture unlocks many capabilities:
- Data Fetching:
- Use RPC to fetch data in a seamless way, e.g. with Telefunc. (Data fetching SSR hooks will be a thing of the past: no more Next.js
getServerSideProps()
nor vite-plugin-ssr
's onBeforeRender()
.) - Expect your GraphQL tools to significantly improve, both on performance and DX. (Also expect new tools such as Vilay.)
- Fundamentally improved mobile performance. (Mobile users can progressively load the page as data is fetched, before even a single line of JavaScript is loaded. Especially important for low-end or poorly-connected devices.)
- Progressive Hydration. (Page is interactive before even the page has finished loading.)
The problem: The current React 18 Streaming architecture is low-level and its ergonomics are cumbersome. (E.g. there is no standard way for library authors to take advantage of the new streaming architecture.)
The solution: react-streaming
.
Get Started
-
Install
npm install react-streaming
-
Server-side
import { renderToStream } from 'react-streaming/server'
const {
pipe,
readable
} = await renderToStream(<Page />)
-
Client-side
import { ReactStreaming } from 'react-streaming/client'
const page = (
<ReactStreaming>
<Page />
</ReactStreaming>
)
Options
const options = {
}
await renderToStream(<Page />, options)
-
options.disable?: boolean
: Disable streaming.
<Page>
is still rendered to a stream, but the promise const promise = renderToStream()
resolves only after the stream has finished. (This effectively disables streaming from a user perspective, while unlocking React 18 Streaming capabilities such as SSR <Supsense>
.)
-
options.seoStrategy?: 'conservative' | 'google-speed'
-
conservative
(default): Disable streaming if the HTTP request originates from a bot. (Ensuring bots to always see the whole HTML.)
-
google-speed
: Don't disable streaming for the Google Bot.
- Pro: Google ranks your website higher because the initial HTTP response is faster. (To be researched.)
- Con: Google will likely not wait for the whole HTML, and therefore not see it. (To be tested.)
-
Custom SEO strategy: use options.disable
. For example:
const disable = false
const disable =
isBot(userAgent) &&
!['googlebot', 'some-other-bot'].some(n => userAgent.toLowerCase().includes(n))
await renderToStream(<Page />, { disable })
-
options.userAgent?: string
: The HTTP User-Agent request header. (Needed for options.seoStrategy
.)
-
options.webStream?: boolean
: Use Web Streams instead of Node.js Streams in Node.js. (Node.js 18 released Web Streams support.)
-
options.onBoundaryError?: (err: unknown) => void
: Called when a <Suspense>
boundary fails. See Error Handling.
-
const { streamEnd } = await renderToStream(<Page />)
const success: boolean = await streamEnd
if (success) {
} else {
}
Note that streamEnd
never rejects.
⚠️
Read Error Handling before using streamEnd
. In particular, do not use success
to change the behavior of your app/stream (because React automatically takes care of gracefully handling <Suspense>
failures).
Error Handling
The promise await renderToStream()
resolves after the page shell is rendered. This means that if an error occurs while rendering the page shell, then the promise rejects with that error.
:book: The page shell is the set of all components before <Suspense>
boundaries.
try {
await renderToStream(<Page />)
} catch(err) {
}
The stream returned by await renderToStream()
doesn't emit errors.
:book: If an error occurs during the stream, then that means that a <Suspense>
boundary failed.
Instead of emiting a stream error, React swallows the error on the server-side and retries to resolve the <Suspense>
boundary on the client-side.
If the <Suspense>
fails again on the client-side, then the client-side throws the error.
This means that errros occuring during the stream are handled by React and there is nothing for you to do on the server-side. That said, you may want to gracefully handle the error on the client-side e.g. with react-error-boundary
.
You can use options.onBoundaryError()
for error tracking purposes.
Bonus: useAsync()
import { useAsync } from 'react-streaming'
function StarWarsMovies() {
return (
<div>
<p>List of Star Wars movies:</p>
<Suspense fallback={<p>Loading...</p>}>
<MovieList />
</Suspense>
</div>
)
}
function MovieList() {
const movies = useAsync(async () => {
const response = await fetch('https://star-wars.brillout.com/api/films.json')
return response.json()
})
return (
<ul>
{movies.forEach((movie) => (
<li>
{movie.title} ({movie.release_date})
</li>
))}
</ul>
)
}
⚠️
useAsync()
currently doesn't work because of a React bug: #24669 - Bug: useId()
not working inside <Suspense>
.
Get Started (Library Authors)
react-streaming
enables you to suspend React rendering and await something to happen. (Usually data fetching.)
The novelty here is that it's isomorphic:
- It works on the client-side, as well as on the server-side (while Serve-Side Rendering).
- For hydration, data is passed from the server to the client. (So that data isn't loaded twice.)
You have the choice between three methods:
useAsync()
: Highest-level & easiest.useSsrData()
: High-level & easy.injectToStream()
: Low-level and highly flexible (both useAsync()
and useSsrData()
are based on it). Easy & recommended for injecting script and style tags. Complex for data fetching (if possible, use useSsrData()
or useAsync()
instead).
useAsync()
For how to use useAsync()
, see example above.
useSsrData()
import { useSsrData } from 'react-streaming'
function SomeComponent() {
const key = 'some-unique-key'
const someAsyncFunc = async function () {
const value = 'someData'
return value
}
const value = useSsrData(key, someAsyncFunc)
assert(value === 'someData')
}
If <SomeComponent>
is rendered only on the client-side, then useSsrData()
is essentially a
cache that never invalidates. (If you want to re-run someAsyncFunc()
, then change the key.)
If <SomeComponent>
is rendered on the server-side (SSR), it injects the
resolved value into the stream and the client-side picks up the injected value. (So that the
client-side doesn't call someAsyncFunc()
but, instead, re-uses the value resolved on
the server-side.)
This is for example how useAsync()
is implemented:
import { useId } from 'react'
import { useSsrData } from 'react-streaming'
function useAsync(asyncFn) {
const id = useId()
return useSsrData(id, asyncFn)
}
injectToStream()
injectToStream(htmlChunk: string)
allows you to inject strings to the current stream.
There are two ways to access injectToStream()
:
- With
renderToStream()
:
import { renderToStream } from 'react-streaming/server'
const { injectToStream } = await renderToStream(<Page />)
- With
useStream()
:
import { useStream } from 'react-streaming'
function SomeComponent() {
const stream = useStream()
if (stream === null) {
}
const { injectToStream } = stream
}
Usage examples:
injectToStream('<script type="module" src="/main.js"></script>')
injectToStream('<styles>.some-component { color: blue }</styles>')
injectToStream(`<script type="application/json">${JSON.stringify(someData)}</script>`)
For a full example of using injectToStream()
, have a look at useSsrData()
's implementation.