Apr 26, 2022
New startTransition API in React
What is a Transition?
A transition is a new concept in React to distinguish between urgent and non-urgent updates.
- Urgent updates reflect direct interaction, like typing, clicking, pressing, and so on.
- Transition updates transition the UI from one view to another.
Urgent updates like typing, clicking, key pressing need immediate response to match our intuitions about how physical objects behave. Otherwise they feel "wrong". However, transitions are different because the user doesn’t expect to see every intermediate value on screen.
For example, when you type in a search box you expect the input field itself to respond and show the typed character immediately when you press a key. However, the actual results may transition separately and a small delay would be often expected. If you change the search term again before the results are done rendering, you only care to see the latest results.
In React 18 we can use the startTransition
API to mark any state updates as non-urgent.
In the code block below, there are two state updates - one that is urgent and one that is marked as non-urgent as it's wrapped inside the startTransition
callback:
import {startTransition} from 'react';
// Urgent: Show what was typed
setInputValue(input);
// Mark any state updates inside as transitions (non-urgent)
startTransition(() => {
// Transition: Show the results
setResults(input);
});
Non-urgent updates will be interrupted if more urgent updates like clicks or key presses come in. If a transition gets interrupted by the user (for example, by typing multiple characters in a row), React will throw out the stale rendering work that wasn’t finished and render only the latest update.
Demo Application: Performance with and without startTransition
We've built a simple application to demonstrate the power of the new startTransition
API.
The application is basically a "Text Search" feature that provides the user with the ability to search a term inside a long block of text (in our case about 60000 characters).
Our application needs to provide two features:
- To render an input that allows the user to type a search term.
- To highlight any characters that match (case insensitive) with the typed search term.
The naive algorithm we used to get the matched terms is:
- Construct a regex from the search term that will match any characters (case insensitive).
- Add the
highlight
class to all the spans of the matched characters.
The code for the applyFilter
function is seen below:
const applyFilter = (query, setFilteredNode) => {
setFilteredNode(() => {
if (!query || query.trim().length === 0) {
return INITIAL_VALUE;
}
const regex = new RegExp(
query.trim().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"),
"ig"
);
const node = TEXT.replaceAll(regex, (s) => `##${s}##`)
.split("##")
.map((s, i) => (
<span key={i} className={i % 2 === 1 ? "highlight" : ""}>
{s}
</span>
));
return node;
});
};
Obviously we could come up with more optimized algorithms - and this regex seriously hinders the performance - however our task here is to "create" a costly function that results in a heavy React state update so that we can see if the startTransition
API will improve the situation.
You can find all the code of the app here.
1st Implementation: Unoptimized without startTransition
Our first implementation is a component with two simple state updates, one for the input value and one that will apply the filtered results. Whenever the input value changes, the function applyFilter
will be called. Both state updates are marked as urgent by default:
import {useState} from 'react';
const Unoptimized = () => {
const [query, setQuery] = useState("");
const [filteredNode, setFilteredNode] = useState(INITIAL_VALUE);
const onInputChange = (e) => setQuery(e.target.value);
useEffect(() => {
applyFilter(query, setFilteredNode);
}, [query]);
return (
<>
<div className="input">
<input onChange={onInputChange} />
</div>
<div className="text">{filteredNode}</div>
</>
);
};
Unoptimized
You can see that the characters are not displaying immediately in the input field and the UI feels a bit "slow":
Unoptimized with CPU 4x slowdown
We applied a 4x CPU slowdown (from Chrome dev tools) to better emulate an average CPU or a mobile device. We can see that the UI definitely feels slow and not smooth at all:
2nd Implementation: Optimized with startTransition
In our second implementation we utilize the startTransition
API. Specifically we make use of the useTransition
hook which returns a pending
state as well - we use that state to indicate to the user that a transition is still being processed by blurring the screen (we could also show a loader).
We mark the state update of the results as non-urgent by wrapping it inside a startTransition
callback:
import {useState, useTransition} from 'react';
const Optimized = () => {
const [query, setQuery] = useState("");
const [filteredNode, setFilteredNode] = useState(INITIAL_VALUE);
const [pending, startTransition] = useTransition();
const onInputChange = (e) => setQuery(e.target.value);
useEffect(() => {
startTransition(() => {
applyFilter(query, setFilteredNode);
});
}, [query, startTransition]);
return (
<>
{pending && <div className="fade" />}
<div className="input">
<input onChange={onInputChange} />
</div>
<div className="text">{filteredNode}</div>
</>
);
};
Optimized
Look how fast the characters are displayed on the input screen; it feels natural, smooth and what the user would expect. We blur the screen while the pending
variable is true to indicate to the user that a transition is still in process.
Optimized with CPU 4x slowdown
We even applied a CPU 4x slowdown to examine how the UI responds. It looks good, even in that scenario!
Alternative solutions prior to React 18?
Before React 18 we did not have the startTransition
API, so how do we fix it in earlier React versions?
The most common solution to face issues like this and to avoid heavy repetitive state updates was to make use of debounce or throttle techniques.
For example we could utilize this awesome use-debounce library and wrap the heavy state update of the results in a debounced callback like this:
import {useState} from 'react';
import {useDebouncedCallback} from 'use-debounce';
const Debounced = () => {
const [query, setQuery] = useState("");
const [filteredNode, setFilteredNode] = useState(INITIAL_VALUE);
const onInputChange = (e) => setQuery(e.target.value);
const debounced = useDebouncedCallback(
(query, setFilteredNode) => {
applyFilter(value, setFilteredNode);
},
100 // debounce 100 ms
);
useEffect(() => {
debounced(query, setFilteredNode);
}, [query, startTransition]);
return (
<>
{pending && <div className="loader" />}
<div className="input">
<input onChange={onInputChange} />
</div>
<div className="text">{filteredNode}</div>
</>
);
};
But there are still a few problems with this approach.
First, the applyFilter
function cannot be called in under 100ms from when the user finishes typing (or whatever amount of time you choose). On the other hand, by using the startTransition
API, the heavy processing of the results starts as soon as possible, without having to wait an arbitrary amount of time.
Secondly, even if we used throttle instead of debounce to try to solve the first issue, the work of processing the results is un-interruptable. That means that if new urgent updates come in (like key pressing) while the results are still being processed the UI inevitably would be unresponsive. Whereas with the startTransition
API, the processing work will be interrupted when the urgent updates come in, keeping in this way the input field responsive.
The End
I hope you found the article informative.
See you soon! 🙂
Newsletter
Subscribe to my mailing list
Subscribe to get my latest content by email. I won't send you spam, I promise. Unsubscribe at any time.