layout: "../layouts/BlogPost.astro" title: "React 18 Suspense and startTransition" slug: react-18-suspense-transition description: "" added: "Oct 7 2023" tags: [react]
A key property of Concurrent React is that rendering is interruptible. With synchronous rendering, once an update starts rendering, nothing can interrupt it until the user can see the result on screen. In a concurrent render, this is not always the case. React may start rendering an update, pause in the middle, then continue later. It may even abandon an in-progress render altogether.
Concurrent React is opt-in — it’s only enabled when you use a concurrent feature. The new root API in React 18 enables the new concurrent renderer, which allows you to opt-into concurrent features. Continue to read How to Upgrade to React 18.
// Before
import { render } from 'react-dom';
const container = document.getElementById('app');
render(<App tab="home" />, container);
// After
import { createRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App tab="home" />);
Consider typing in an input field that filters a list of data. Here, whenever the user types a character, we update the input value and use the new value to search the list and show the results. For large screen updates, this can cause lag on the page while everything renders, making typing or other interactions feel slow and unresponsive. Conceptually, there are two different updates that need to happen. The first update is an urgent update, to change the value of the input field. The second, is a less urgent update to show the results of the search.
Until React 18, all updates were rendered urgently. A transition is a new concept in React to distinguish between urgent and non-urgent updates.
import { startTransition } from 'react';
// Urgent: Show what was typed
setInputValue(input);
// Mark any state updates inside as transitions
startTransition(() => {
// Transition: Show the results
setSearchQuery(input);
});
startTransition
allows you to mark certain updates in the app as non-urgent, so they are paused while the more urgent updates like clicks or key presses come in. If a transition gets interrupted by the user, React will throw out the stale rendering work that wasn’t finished and render only the latest update.
By default, React 18 still handles updates as urgent. You can use startTransition
to wrap any update that you want to move to the background. (If some state update causes a component to suspend, that state update should be wrapped in a transition.)
How is it different from
setTimeout
?
startTransition
is not scheduled for later likesetTimeout
is. The function passed tostartTransition
runs synchronously, but any updates inside of it are marked as “transitions”. React will use this information later when processing the updates to decide how to render the update. This means that we start rendering the update earlier than if it were wrapped in a timeout.- If the user is still typing or interacting with the page when the timeout fires, they will still be blocked from interacting with the page. But state updates marked with
startTransition
are interruptible, so they won’t lock up the page.
What if you want to display something on the search results while waiting for the expensive UI render to finish? For this, we can use the isPending
flag that comes from the useTransition
hook.
function App() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [deferredQuery, setDeferredQuery] = useState(query);
useEffect(() => {
// Hi React, schedule this function for later
startTransition(() => {
setDeferredQuery(query);
});
}, [query]);
return (
<div>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
/>
{ isPending ? <Spinner /> : <List q={deferredQuery} /> }
</div>
)
}
We can also use useDeferredValue
for the query used in rendering the list, allowing React to prioritize more urgent input changes over re-rendering the list. useTransition
returns isPending and useDeferredValue
you can do value !== deferredValue.
function App() {
const [query, setQuery] = useState('');
// Get a deferred version of that value
const deferredQuery = useDeferredValue(query);
return (
<div>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
/>
{ query !== deferredQuery ? <Spinner /> : <List q={deferredQuery} /> }
</div>
)
}
If we didn't use useDeferredValue
, the expensive computation ("List" component here) would run on every keystroke, which could lead to performance issues. By deferring the update of the text value, we ensure that the expensive computation only runs when the text value has stabilized.
Suspense allows you to render a fallback component while a component is waiting for some asynchronous operations.
Suspense is used on the client in React 16, but it would throw an error when used in SSR. Suspense and code-splitting using React.lazy
were not compatible with SSR, until React 18.
import React, { lazy, Suspense } from 'react';
const LazyComments = lazy(() => import('./Comments'));
const Component = () => (
<Suspense fallback={<div>Loading...</div>}>
<LazyComments />
</Suspense>
);
SSR lets you render your React components on the server into HTML and send it to the user. It's useful because it lets users with worse connections start reading or looking at the content while JavaScript is loading. The problem with SSR today is a “waterfall”: fetch data (server) → render to HTML (server) → load code (client) → hydrate (client). Neither of the stages can start until the previous stage has finished. This is why it’s inefficient. To solve this, React created Suspense.
React 18 includes architectural improvements to React SSR performance (with renderToPipeableStream
and <Suspense>
). It lets you use <Suspense>
to break down your app into smaller independent units. As a result, your app’s users will see the content sooner and be able to start interacting with it much faster. When the data for a component is ready on the server, React will send additional HTML into the same stream, as well as a minimal inline <script>
tag to put that HTML in the “right place”. Read "New Suspense SSR Architecture in React 18": https://github.com/reactwg/react-18/discussions/37
import { renderToPipeableStream } from 'react-dom/server';
app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App />, {
// This points to the JavaScript file used to bootstrap the client-side code.
bootstrapScripts: ['/main.js'],
// The `onShellReady` callback fires when the entire shell has been rendered.
// The part of your app outside of any `<Suspense>` boundaries is called the shell.
// By the time `onShellReady` fires, components in nested `<Suspense>` might still be loading data.
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});
<Suspense>
allows for server-side HTML streaming and selective hydration on the client:
renderToString
to the new renderToPipeableStream
method.hydrateRoot
on the client and then start wrapping parts of your app with <Suspense>
.Understand Node stream:
The HTTP response object is a writable stream. All streams are instances ofEventEmitter
. They emit events that can be used to read and write data. Thepipe()
function reads data from a readable stream as it becomes available and writes it to a destination writable stream. All that thepipe
operation does is subscribe to the relevant events on the source and call the relevant functions on the destination. Thepipe
method is the easiest way to consume streams.
One of the key benefits of React Suspense is that it lets you render as you fetch. Basically the React Component wrapped in Suspense tags, will start to try to render continuosly and it expects for a method that throws a new promise until the original promise is not resolved, that's how it knows that it has to keep rendering the fallback. So you need to pass a resource with a very specific shape, that's why you need a wrapper. Fetching libraries like react-query or SWR will implement the wrapper themselves, so you won't have to care of that part.
// Use Suspense without a 3rd party library
const OuterComponent = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<DataLoader />
</Suspense>
);
}
let data;
const DataLoader = () => {
if (!data) {
throw fetchUserProfile(userId)
.then((profile) => { data = profile });
}
return <UserProfile data={data} />
}
Streaming enables you to progressively render UI from the server, which allows you to break down the page's HTML into smaller chunks and progressively send those chunks from the server to the client. This enables parts of the page to be displayed sooner, without waiting for all the data to load before any UI can be rendered. Streaming is built into the Next.js App Router by default.
import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
export default function Posts() {
return (
<section>
<Suspense fallback={<p>Loading feed...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading weather...</p>}>
<Weather />
</Suspense>
</section>
)
}
startTransition
These two APIs are designed for different use cases and can absolutely be used together. Read from https://github.com/reactwg/react-18/discussions/94
When a component suspends, the closest parent Suspense boundary switches to showing the fallback. This can lead to a jarring user experience if it was already displaying some content. To prevent the whole site layout got replaced by BigSpinner
, you can mark the navigation state update as a transition with startTransition
. This tells React that the state transition is not urgent, and it’s better to keep showing the previous page instead of hiding any already revealed content.
export default function App() {
return (
<Suspense fallback={<BigSpinner />}>
<Router />
</Suspense>
);
}
function Router() {
const [page, setPage] = useState('/');
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
let content;
if (page === '/') {
content = (
<IndexPage navigate={navigate} />
);
} else if (page === '/the-beatles') {
content = (
<ArtistPage artist={{ id: 'the-beatles' }} />
);
}
return (
<Layout>
{content}
</Layout>
);
}
function BigSpinner() {
return <h2>Loading...</h2>;
}
startTransition
lets you show a pending indicator until that render completes, and avoid retriggering Suspense boundaries.Render-as-you-fetch is a pattern that lets you start fetching the data you will need at the same time you start rendering the component using that data. Used along with
Suspense
, the data call is made while the component is being rendered. While the data is being loaded the component is in a suspended state andSuspense
is used to show a fallback UI.
useOptimistic
use caseWhen the app is settled, the server is their source of truth. Whenever the server responds with a page, the checkboxes should reflect the URL that was used to generate that page. When the app is transitioning, the client is their source of truth. If we press a checkbox and trigger a server-side refresh, the client should immediately reflect our press while the app is preparing the next page.
This is exactly what useOptimistic
was designed for. It gives you some local React state that's seeded with server-side data, but lets you make temporary changes while your app is transitioning. Once all pending transitions settle, useOptimistic
automatically discards any changes you made, and resets its value to the latest version of your server-side data.
//
// 1. `optimisticState` is the resulting optimistic state. It is equal to state
// unless an action is pending, in which case it is equal to the value returned by `updateFn`.
// 2. `addOptimistic` is the dispatching function to call when you have an optimistic update,
// it takes one argument `optimisticValue`, and will call the `updateFn`.
const [optimisticState, addOptimistic] = useOptimistic(
// the value to be returned initially
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state
// with optimistic value
}
);
// https://buildui.com/posts/instant-search-params-with-react-server-components
export default function GenresPanel({ genres }: { genres: string[] }) {
let [optimisticGenres, setOptimisticGenres] = useOptimistic(genres);
let [isPending, startTransition] = useTransition();
let router = useRouter();
return (
<>
<input
name={genre}
type="checkbox"
checked={optimisticGenres.includes(genre)}
onChange={(e) => {
let { name, checked } = e.target;
let newGenres = checked
? [...optimisticGenres, name]
: optimisticGenres.filter((g) => g !== name);
let newParams = new URLSearchParams(
newGenres.map((genre) => ["genre", genre])
);
startTransition(() => {
setOptimisticGenres(newGenres);
router.push(`?${newParams}`);
});
}}
/>
</>
);
}