The React JS dev team announced some exciting changes several months ago - React would be getting a "Concurrent Mode". Essentially this would allow React to perform multiple UI renders concurrently. Of course, JavaScript is single threaded and true concurrency is an illusion, but the new features will allow web apps (and Native Apps once these features hit React Native) to be much more responsive and snappy than they are now with less effort and custom code from the developer to make this happen.
Concurrent mode is now available in the experimental build of React, so let's dig in and see how to use the shiny new API.
In this post I'll be showing you how to use the
Suspense
API with the useTransition
hook. There is yet another hook, useDeferredValue
, that serves a slightly different, but equally important purpose that I'll cover in a follow up post.You'll notice that the
Suspense
API has been present in React since v16.6. This is in fact, the same API that is being extended to do more in the React experimental build. In React 16.6, Suspense can only be used for one purpose: code splitting and lazily loading components using React.lazy()
This has been discussed a lot through talks and blogs and in the official documentation already, so I'll keep it brief - Concurrent React allows us to implement a "render as you fetch" pattern, which renders components as the data needed to populate them are fetched concurrently. React renders as much as it can without the available data, and renders the component that requires the fetched data as soon as the data becomes available. During this time these components are said to be "suspended".
The commonly used existing approaches are "fetch then render", which fetches all the data needed first before rendering the component, and "render then fetch" which renders a component and then the component itself fetches the data required to populate it's children. Both these approaches are slower and have a number of disadvantages that needed workarounds.
For this, I'm using a basic React app I configured manually with Webpack and Babel (Click here for a guide I wrote on how to do that), with the only difference being running:
npm i react@experimental react-dom@experimental --save
To get the experimental versions instead of installing the release versions of
react
and react-dom
.This should also work with React apps created with
create-react-app
by replacing react
and react-dom
with their experimental versions.Since concurrent mode changes how React handles components fundamentally, you'll need to change the
ReactDOM.render()
line in your index.js
to:ReactDOM.createRoot(document.getElementById('root')).render(<App />);
This enables concurrent mode in your app.
I've also set up my
App.js
to render a component called Data
inside which the demo is done.import React from 'react';
import Data from './Data';
const App = () => {
return (
<div>
<p>React Concurrent Mode testing</p>
<Data />
</div>
);
}
export default App;
Now we create
Data.js
import React, { useState, useTransition, Suspense } from 'react';
import DataDisplay from './DataDisplay';
import { dataFetcher } from './api';
const initialData = { read: () => { return { foo: "initial" } } };
const Data = () => {
const [data, setData] = useState(initialData);
const [count, setCount] = useState(0);
const [startDataTransition, isDataPending] = useTransition({ timeoutMs: 2000 });
const fetchNewData = () => {
startDataTransition(() => {
setData(dataFetcher())
})
}
return (
<div>
<Suspense fallback={<p>Loading...</p>}>
<DataDisplay data={data} />
<button disabled={isDataPending} onClick={() => fetchNewData()}>Click me to begin data fetch</button>
</Suspense>
<p>Counter: {count}</p>
<button onClick={() => { setCount(count + 1); }}> Click me to check if the app is still responsive</button>
</div>
)
}
export default Data;
Breaking this down:
dataFetcher
is a function that returns a "special" object that lets React know the states set as this object can be fetched as the components dependent on this state is rendered. These components are "suspended" if the data has not finished fetching. We'll look at how to create the "special object" towards the end.initialData
shows the format of the object returned by dataFetcher
once the data has finished loading. It has a read
function that returns the object with data we need. Ideally, the initialData
should implement some sort of caching function for the last loaded data, but here we just use { foo : "initial" }
.A state which while being updated/fetched causes a component to suspend, must be updated using the
useTransition
hook. This hook returns a pair of values - a function that takes a callback function in which you set the state, and a boolean that lets us know when the transition is taking place.The argument passed to
useTransition
is an object that tells React how long to wait before suspending the component. To understand it, think of it this way: We have some data on screen, and we're fetching some new data to replace it. We want to show a spinner while the new data is being fetched, but it's okay for the user to see the old data for a second, or maybe half a second before the spinner is shown. This delay is mentioned in this object.This is useful in cases when showing stale data till new data is loaded is desirable, and also to prevent the spinner from showing up for a fraction of a second (causing what is percieved as jitter) on fast data fetch operations.
Let's take a closer look at
Suspense
itself:<Suspense fallback={<p>Loading...</p>}>
<DataDisplay data={data} />
<button disabled={isDataPending} onClick={() => fetchNewData()}>Click me to begin data fetch</button>
</Suspense>
Any component that should be suspended is wrapped inside the
Suspense
component. Inside it's fallback
prop, we pass the component that should be shown instead while the component inside is waiting for data. This is usually a spinner or loading indicator of some sort to visually indicate to the user something is happening, so it doesn't appear as if the page hasn't responded to the click.Here I've used the
isDataPending
boolean to disable the button while data is being fetched, preventing the user from pressing the button multiple times and sending multiple requests - A nice bonus we get from the pattern.Talking about the page remaining responsive - it does. All the JavaScript in the page continues to work while the component is suspended and data is being fetched. The counter and the button to increment it can be used to confirm this.
DataDisplay
is a simple component that takes the data and calls it's read function and displays the result.import React, { memo } from 'react';
const DataDisplay = ({ data }) => {
return (
<h3>{data.read().foo}</h3>
)
}
export default memo(DataDisplay);
memo
is used here to prevent this component from re-rendering when it's parent re-renders, and is essential for concurrent mode to work.Finally, we look at
dataFetcher
and the other things inside api.js
export const dataFetcher = (params) => {
return wrapPromise(fetchData(params))
}
const wrapPromise = (promise) => {
let status = "pending";
let result;
let suspender = promise.then(
r => {
status = "success";
result = r;
},
e => {
status = "error";
result = e;
}
);
return {
read() {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw result;
} else if (status === "success") {
return result;
}
}
};
}
const fetchData = (params) => {
// In a real situation, use params to fetch the data required.
return new Promise(resolve => {
setTimeout(() => {
resolve({
foo: 'bar'
})
}, 3000);
});
}
As you can see,
dataFetcher
simply returns wrapPromise(fetchData())
, and fetchData()
is a function that makes the actual request for the data. In a real situation you'll be using fetch()
inside fetchData()
with the params
passed to it, or load data from some place else. Here I'm using setTimout
to return a Promise
object that intentionally introduces a 3 second delay before returning { foo : 'bar' }
.wrapPromise
is responsible for getting things to integrate with React, and should be straightforward if you've used Promises before. It returns the result if the fetch was successful, throws the error if it was not, and throws a Promise
with "pending" state if the operation has not completed yet.All of this put together results in this:
Initially the data shown is "Initial". Then I click the button to begin data fetch. According to our configuration, the button is disabled immediately and nothing happens for 2 seconds. Then the component suspends, showing the fallback "Loading...". Then finally the data fetching is completed and the component updates to show "bar". During this whole time the remaining app (shown by the counter here) remains active.
As an ending note,
Suspense
can work without the useTransition
hook, but only if the required data is not part of the state.It's worth saying here that the oddly specific functions in
api.js
is not too important, as it is expected that many popular data fetching libraries will support them in the future. React also does not recommend using concurrent mode in production because, well, it's still "experimental".But it is expected that soon leveraging concurrent mode will be the de-facto way to get around lengthy operations in UI, allowing the creation of user experiences that remain consistent across many devices with vastly different processing powers and network connectivity speeds.