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 until the filtering is finished. Why?
Because React processes the state update and filtering logic in one big synchronous chunk.
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);
};
}
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
State change is renderered and committed, and user is on the hard work tab without freezing!
React start to filter the data for profile, but remember, it is a non-urgent work!
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 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 and did not waited for the hard work tabs filtering to complete!
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 stays snappy and users can keep tapping around even while heavy stuff is going on in the background.
→ Found this helpful? Let me know or share it with a fellow React Native dev 🚀