startTransition, a gateway to the using React's s one of the greatest features, concurrency. In this post, I will try to explain how to use it and also how it works under the hood. Letβs start with a brief overview of concurrency.
Concurrent React is more important than a typical implementation detail β itβs a foundational update to Reactβs core rendering model. So while itβs not super important to know how concurrency works, it may be worth knowing what it is at a high level.
- March 29, 2022 by The React Team
Yes, itβs been a while β 3+ years β but I need to touch this great feature and explain it to understand startTransition api. By the way, you can opt in to concurrency mode in React v16.
To enabling the concurrent mode you will use createRoot in old times
//old implementation (React 16)
import { createRoot } from "react-dom";
createRoot(document.getElementById("root")).render(<App />);In React 18+, concurrency is the default β no need to opt-in. The term 'Concurrent Mode' is no longer used as a special opt-in flag. It's just 'Concurrent React' now.
Concurrent React makes rendering interruptible. It lets React discard, pause and resume rendering so the UI stays responsive even during heavy updates.
In other words, now the React became asyncronus in rendering phase.
Without Concurrent React: Updates are synchronous β the entire render must finish before anything changes on screen.
π€ What Was It Like Before?
import {applyIntensiveFilter} from "@functions"
import {data} from "@actions"
import {useState} from "react"
const function Component (data){
const [activeTab,setActiveTab]=useState(undefined)
const [filteredData,setFilteredData] = useState(data)
const [loading,isLoading] = useState(false)
const handleClick = (tabKey:string)=>{
setIsLoading(true);
//This creates the headache
//Takes too long and freezes the main thread
setFilteredData(applyIntensiveFilter(data,tabKey));
setIsLoading(false);
setActiveTab(tabKey);
}
}The code above is a simple example of a react component that has tabs and applies different level of computing intensive heavy filtering, depending on the tab.
When you switch between tabs, your entire UI might freeze. Why?
Because both the JS computation AND the re-render run synchronously β React cannot do anything else until both are fully complete.
During that time, it can'st respond to user input, paint updates, or even register other tab clicks.
Darker colors in the end visualizes tab key state change
Red color visualizes loading state change
Here's a live example where we filter a large dataset without using startTransition. The work that will be done by the React is visualized in the timeline. Try clicking between tabs quickly multiple times and watch how the UI freezes.
As you can see, the clicks are queued. You might wonder: if React is busy, how are these clicks even registered? The answer is β the browser handles input events, and queues them. Once React is free again, it processes the latest state and responds accordingly
π§ͺ What is Start Transition
Finally we are here π. startTransition lets you mark an update as non-urgent. This allows React to prioritize more urgent interactions β like clicks, text input, or animations β and pause or discard the lower-priority work in startTransition.
import { applyIntensiveFilter } from "@functions";
import { data } from "@actions";
import { startTransition } from "react";
import { useState } from "react";
export function Component(data) {
const [activeTab, setActiveTab] = useState(undefined);
const [filteredData, setFilteredData] = useState(data);
const handleClick = (tabKey: string) => {
//This is not urgent
startTransition(() => {
setFilteredData(applyIntensiveFilter(data, tabKey));
});
//This is urgent for UX
setActiveTab(tabKey);
};
}β οΈ Important distinction
startTransition helps with expensive re-renders β React can pause, interrupt, or discard them to stay responsive. It does NOT help with expensive JS computations. If your filter function takes 2 seconds to run, the main thread is blocked for those 2 seconds regardless of startTransition. The real benefit here is React not being forced to render stale intermediate results β it can skip straight to the latest state.
User is on home tab currently. At some time, our user pressed on profile and feed sequentially, nearly in the same time
Lets see what is happening step by step, how the priorities are changed!
Step 1οΈβ£
User pressed to profile which has this unit of work
filtering work
tab change work
This order will change, because filtering work is now has 'non-urgent' priority
Queue
The tab change (urgent) is rendered immediately.
React starts rendering the filtered data for profile, but this re-render is non-urgent.
β οΈ Note: if applyIntensiveFilter itself is computationally heavy, it still runs synchronously before React can schedule anything. startTransition only de-prioritizes the re-render, not the JS computation.
Queue
Step 2οΈβ£
Now user clicks feed button. This is the unit of work that we will add this time
filtering work
tab change work
We have a leftover filtering render work in timeline(the profile filtering).React will continue to render and commit updates like this
Queue
There is no green filtering work pill in the last queue visualization, right?
If a new startTransition call comes in before the previous one finishes, React may discard the previous render work if it wasnβt committed yet.
Now user is on the feed tab without waiting for the previous profile re-render to complete.
React discarded that unfinished render work and started fresh for feed.
In the last implementation with startTransition, we dont have any loading state! We could refactor like this to introduce a loading state.
import { applyIntensiveFilter } from "@functions";
import { data } from "@actions";
import { startTransition, useState } from "react";
export function Component(data) {
const [activeTab, setActiveTab] = useState(undefined);
const [filteredData, setFilteredData] = useState(data);
//we add the loading state!
const [isLoading, setIsLoading] = useState(false);
const handleClick = (tabKey: string) => {
setIsLoading(true);
startTransition(() => {
setFilteredData(applyIntensiveFilter(data, tabKey));
});
setIsLoading(false);
setActiveTab(tabKey);
};
}But this implementation will not work as expected, cause as you remember, Concurrent React de-prioritize the work in the startTransition.
So when button clicked, loading state will change to true, but immediately false value will be set.
But we can achieve loading behaviour with useTransition hook! (We can do other things, but this is the clean way! )
import { applyIntensiveFilter } from "@functions";
import { data } from "@actions";
import { startTransition, useTransition, useState } from "react";
export function Component(data) {
const [activeTab, setActiveTab] = useState(undefined);
const [filteredData, setFilteredData] = useState(data);
//we add the loading state through useTransition hook
const [isLoading, startTransition] = useTransition();
const handleClick = (tabKey: string) => {
//No need to write extra code for loading
startTransition(() => {
setFilteredData(applyIntensiveFilter(data, tabKey));
});
setActiveTab(tabKey);
};
}We can now show loading state in the UI!
Concurreny is not only about performance, it also enables some great features that came up recently like data fetching with suspense. Itβs worth exploring those too!
User experience matters. Without startTransition, a filter or tab switch could freeze the UI, especially in low end devices like mobile phones. With it, React can deprioritize and even discard expensive re-renders to keep urgent interactions responsive. However, if the computation itself (the filter function) is the bottleneck, startTransition alone won't help β for that, you'd need Web Workers or chunked processing to truly move work off the main thread.
β Found this helpful? Let me know or share it with a fellow React Native dev π