React 18: Concurrent Render Magic
React 18 has some important new features that can improve responsiveness and make for smoother screen transitions:
- Concurrent Rendering: Rendering is no longer a synchronous process. React can interrupt rendering between individual component renders to process events. If these events result in state updates, the course of rendering may be altered — in some cases aborting the rest of the render or yielding to render more important updates.
- Transitions: Transitions are the key to using concurrent rendering. In a transition state updates are marked as non-urgent. These updates trigger non-urgent renders for dependent components, readying content for the transition. Meanwhile the same components may also be re-rendered without the non-urgent updates so more urgent updates can be shown. In effect components have a “dual life” during the transition.
- State Branching: The ability to maintain multiple versions of state is what enables this “dual life”. React maintains the old and new values of state that is part of the transition.
- Suspense: Allows components to “suspend” and display fall-back content while a suspense-compatible API is fetching data. If the suspended components are part of a transition, the transition is extended while the component is suspended and components continues to render on-screen without the state update, allowing the previous version of content to remain on the screen and more urgent updates to be reflected.
Sound crazy? It is a little bit until you see some real-world examples. Here are two cases we will explore further with examples:
- Responsive UI — You have a complex component tree. It renders slow enough that it may be out-of-date before rendering is complete because the user signals further changes in an input field or slider. Previously you waited for the render of all components to complete before updating the input element. With concurrent rendering you can ensure the input is immediately responsive to changes and that only the final render is visible.
- Orderly Transitions— When a screen transition occurs that causes data to be fetched you may not wish to update the screen until the data is fetched and all elements of the transition are ready. This makes transitions smoother with less “flashing” of intermediate states.
Now let’s dive into the details of these use-cases
Keeping Your UI Responsive
Here is an example of a fractal tree that demonstrates how concurrent rendering and transitions can keep a user interface responsive in the face of high-overhead rendering.
If you drag the “Largest ” slider you see that it lags because it takes time to re-render these fractal trees. The slider won’t be update until all trees are rendered which occurs for each tiny movement. When you check the box to use “React 18 Transitions” you will see how responsive the slider becomes.
So how does this work? Let’s start with the state for this application. While transitions are designed to be used with useState other state managers also support transitions. For this example we will use Proxily. You can read more about Proxily’s flexible state management here. This is our shared state:
import {memoize, observable} from "proxily";const minX = 0;
const maxX = 400;
const minY = 0;
const maxY = 600;class Forest {
color = "#239923";
treeCount = 70;
minSize = 50;
maxSize = 150;
get trees() {
console.log("Computing trees");
const trees : Array<any> = [];
for (let ix = 0; ix < this.treeCount; ++ix)
trees.push([
Math.random() * (maxX - minX) + minX,
Math.random() * (maxY - minY) + minY,
]);
console.log("reorg trees");
return trees;
}
}memoize(Forest, f => f.trees);
export const state = observable(new Forest());
Forest has all the state needed for our forest including the number of trees and their sizes. The trees getter method returns the positions of the trees. It is memoized so it only recalculates when it’s dependents (treeCount) changes so the trees remain in their place when being resized. A new instance of Forest is created and made observable so components referencing it will be re-rendered when the state changes.
Rendering the tree is pretty simple using our Trees component which is wrapped with Proxily’s observer.
import { observer } from "proxily";
import { state } from "./state";
import Tree from "./Tree";
function Trees() {
return (
<>
{state.trees.map((t, ix) => (
<Tree n={ix} posX={t[0]} posY={t[1]} />
))}
</>
);
};
export default observer(Trees);
The Trees component iterates through the tree positions returned from the trees getter we saw in our state. The Tree component renders an individual tree which is a simple fractal tree implemented with SVG. It is recursive and creates two smaller squares at an angle to make the tree fan out. With so many renders there is enough of a delay that it can’t keep up with user input. User input is handled by the Controls or ControlsWithTransition component, the latter of which is used when you check “With Transitions”.
To use transitions in Proxily you call useObservableTransition which is Proxily’s interface to React’s useTransition .
let [, startTransition] = useObservableTransition();
The startTransition function that is returned is used to wrap the setting of the maximum tree size, creating a non-urgent update to begin the transition:
<RangeSlider
className="RangeSlider"
value={getCurrentValue(state, (state) => state.maxSize)}
min={110}
max={250}
size="lg"
onChange={(e: any) =>
startTransition(
() => (state.maxSize = parseInt(e.target.value, 10))
)
}
/>
We use Proxily’s getCurrentValue to retrieve the current value of the slider so the slider is responsive. That is all there is to it. While simple to implement it is important to understand what is really going on here:
- During the transition the Trees will render as a non-urgent render. The results won’t be shown on the screen until every Tree is rendered.
- The slider is also re-rendered during the transition. Since we used getCurrentValue so it will show the updated value and remain responsive.
- As sliding continues the size is updated again and a new transition starts. The rendering of trees is aborted and rendering starts with the new value.
Orderly Transitions
We saw in the first example how the screen was not updated until newly updated components finished rendering. What if you want to defer displaying new components that depend on fetching data or other asynchronous events. React 18’s improved Suspense component makes this possible. It can display fallback content while data is being fetched with a suspense-compatible API.
const MyContainer = ({contentId}) => {
<Suspense fallback={<Spinner />}>
<MyContent contentId/>
</Suspense>
}function MyContent ({contentId}) {
const content = SuspenseCompatibleFetch(contentId);
return (<div>{content}</div>);
}
If used as part of a transition, the transition won’t complete until the data is fetched and the previous content will remain on the screen.
We will now look at a more fleshed out example. We will use Proxily because it provides a suspense compatible wrapper for normal promises. Here is a random sentence generator that waits 5 seconds before delivering the next sentence. The delay is meant to have the same effect as fetching from a server.
You can see the behavior with and without transitions.
With Proxily you create your state as a class or a plain old Javascript object:
import { observable, suspendable } from "proxily";
import { fetchSentence } from "./sentences";const sentence = {
seq: 0,
nextSentence(seq: number) {
return new Promise((r) =>
setTimeout(() => r(fetchSentence(seq)), 3000));
},
next: function () {
this.seq++;
}
};suspendable(sentence, s => s.nextSentence);export const state = observable(sentence);
Our state has the sequence number (seq) which determines which of the 100 randomly generated sentences is to be returned by the nextSentence method. To simulate a fetch from a server nextSentence waits 3 seconds before calling fetchSentence to return the sentence. To make nextSentence work with Suspense we wrap it with Proxily’s suspendable call. This will cause any component that references it to suspend it’s parent components while the promise is resolving. Finally a next method increments the sequence number.
Now we can create a container for the Suspense:
import { observer } from "proxily";
import { Suspense } from "react";
import Sentence from "./Sentence";
function ContentContainer () {
return (
<Suspense fallback={<p>Fetching next Sentence...</p>}>
<Sentence />
</Suspense>
);
};
export default observer(ContentContainer);
We wrap Sentence in a Suspense with some fallback content.
Finally the Sentence component fetches the next sentence by calling the nextSentence method in our state:
import { observer } from 'proxily';
import {state} from './state';
function Sentence () {
return <p>{state.nextSentence(state.seq)}</p>;
};
export default observer(Sentence);
With that the container will suspend for 5 seconds and then display the next sentence each time we update the sequence number. That occurs in the Controls component which calls state.next:
import { observer } from "proxily";
import { state } from "./state";
function Controls() {
return (
<div>
<button onClick={() => state.next()}>Next Sentence</button>
</div>
);
};export default observer(Controls);
If we wish to wrap this in a transition we use the ControlsWithTransition component instead:
import { observer, useObservableTransition } from "proxily";
import { state } from "./state";
function ControlsWithTransition() {
const [pending, startTransition] = useObservableTransition();
return (
<button disabled={pending}
onClick={() => startTransition(() => state.next())}>
Next Sentence
</button>
);
}
export default observer(ControlsWithTransition);
useObservableTranstion returns a value that can tell you if the transitions is complete (pending) and a function which can be used to wrap a state update (startTransition). Wrapping the state update in startTransition makes the render of dependent component non-urgent. Because the render of the content is suspended while the sentence is being fetched, the transition continues and the old content remains on the screen until the fetch completes.
The pending value is used to dim the button so you have some visual feedback that a transition is in progress.
Summing it Up
React 18’s new concurrent rendering can really help to refine a user interface. While the usage patterns are relatively simple they are not always obvious and it is best to take some time to understand how it really works. Although originally intended for React’s own useState and useContext, concurrent rendering can be use with other state managers including Redux and Proxily.