Concurrent React Mode: Using Suspense and useTransition to Build A Better UX
source link: https://hackernoon.com/concurrent-react-using-suspense-and-usetransition-to-build-better-ux-cman2cdd
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Photo by Irvan Smith on Unsplash
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.
SuspenseAPI with the
useTransitionhook. There is yet another hook,
useDeferredValue, that serves a slightly different, but equally important purpose that I'll cover in a follow up post.
But what about the existing Suspense feature?
SuspenseAPI 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()
The New Way - Render as you fetch
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.
The Setup
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
reactand
react-dom.
create-react-appby replacing
reactand
react-domwith their experimental versions.
Opting in to Concurrent Mode
ReactDOM.render()line in your
index.jsto:
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
This enables concurrent mode in your app.
App.jsto render a component called
Datainside 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;
The Demo
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:
dataFetcheris 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.
initialDatashows the format of the object returned by
dataFetcheronce the data has finished loading. It has a
readfunction that returns the object with data we need. Ideally, the
initialDatashould implement some sort of caching function for the last loaded data, but here we just use
{ foo : "initial" }.
useTransitionhook. 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.
useTransitionis 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.
Suspenseitself:
<Suspense fallback={<p>Loading...</p>}> <DataDisplay data={data} /> <button disabled={isDataPending} onClick={() => fetchNewData()}>Click me to begin data fetch</button> </Suspense>
Suspensecomponent. Inside it's
fallbackprop, 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.
isDataPendingboolean 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.
DataDisplayis 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);
memois used here to prevent this component from re-rendering when it's parent re-renders, and is essential for concurrent mode to work.
dataFetcherand 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); }); }
dataFetchersimply 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
paramspassed to it, or load data from some place else. Here I'm using
setTimoutto return a
Promiseobject that intentionally introduces a 3 second delay before returning
{ foo : 'bar' }.
wrapPromiseis 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
Promisewith "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.
Suspensecan work without the
useTransitionhook, but only if the required data is not part of the state.
Conclusion
api.jsis 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.
- You can find all the code used in the demo here.
- You can find me on Twitter and LinkedIn .
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK