useTransition Hook in React

Sep 22, 2025

Why do we need useTransition hook?

Prior to React version 18, all state updates were treated as equally urgent and were processed synchronously.

When a component's state changed, React would begin a render cycle that was "all-or-nothing". It would calculate all necessary updates, re-render all affected components, and commit those changes to the DOM.

This entire process was uninterruptible. If a particular state update triggered a computationally expensive re-render such as filtering a list with thousands of items or rendering a complex data visualization, the main thread would be blocked until the render was complete.

This blocking behavior leads to a significant degradation in user experience.

For the user, the application would appear to freeze. High-priority interactions, such as typing into a search field or clicking a button, would be queued behind the slow, low-priority render. The result is a sluggish, unresponsive interface where user input lags, and the application feels janky and frustrating to use.

Blocking Rendering

A slow rendering blocks the main thread and makes the input element unresponsive. Try clicking the button and then typing immediately.

Rendering slowly...

Welcome Concurrent Rendering

Concurrent Rendering is the architectural overhaul that addresses the core issue of blocking renders. The most critical property of this new model is that rendering is interruptible.

Unlike the previous synchronous model, React can now start rendering an update, pause its work in the middle to handle a more urgent task, and then resume the paused work later. It can even discard the in-progress work if it becomes stale due to new data or user interactions.

Concurrent Rendering

Rendering is interruptible. React can pause a non-urgent render to handle user input immediately. Try typing while the transition is in progress.

Transition (Non-Urgent)

Using useTransition hook

The useTransition hook is the primary API that empowers developers to explicitly inform React which state updates are non-urgent transitions.

By wrapping a state update in a transition, a developer signals that React can de-prioritize the rendering work associated with that update, allowing urgent updates to be processed without delay.

This declarative approach to scheduling is a significant evolution from older, imperative techniques. Instead of manually defining a fixed delay with debounce or setTimeout, the developer declares the intent of the update ("this is less important").

React's scheduler then uses this information to make intelligent decisions in real-time, adapting to the device's capabilities and the user's actions. It can interrupt a transition if a more urgent update arrives, and it doesn't introduce an artificial delay if the device is fast enough to handle the update quickly.

Syntax

The useTransition hook is called at the top level of a component or a custom hook, just like useState or useEffect. It takes no arguments and returns an array containing exactly two elements:


const [isPending, startTransition] = useTransition();

  1. isPending: A boolean flag that provides real-time feedback about the status of a transition. It is true when a transition initiated by this hook is actively being processed and false otherwise. This flag is crucial for communicating loading or pending states to the user.

  2. startTransition: A function that you use to wrap state updates. Any state update that occurs within the callback function passed to startTransition is marked as a non-urgent transition.

Let's see an example:

blocking-ui.jsx
import React, { useState } from 'react';
import SlowComponent from './SlowComponent';
const App = () => {
const [tab, setTab] = useState<string>('about');
const selectTab = (nextTab: string) => {
setTab(nextTab);
};
return (
<div>
<div>
<button onClick={() => selectTab('about')}>About</button>
<button onClick={() => selectTab('posts')}>Posts</button>
<button onClick={() => selectTab('contact')}>Contact (Slow)</button>
</div>
<div>
{tab === 'about' && <p>This is the About tab.</p>}
{tab === 'posts' && <p>This is the Posts tab.</p>}
{tab === 'contact' && <SlowComponent />}
</div>
</div>
);
};
export default App;

In this example, when the user clicks the "Contact (Slow)" button, the setTab('contact') update triggers the rendering of SlowComponent.

Because this is a synchronous, urgent update, the entire UI freezes. The tab buttons will not visually respond to the click until SlowComponent has finished its expensive render.

concurrent-rendering.jsx
import { useState, useTransition } from 'react';
import SlowComponent from './SlowComponent';
function App() {
const [tab, setTab] = useState<string>('about');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab); // This state update is now a non-urgent transition
});
}
//... same JSX as before
}

With this change, the user experience is transformed.

When the user clicks the contact tab, startTransition function tells React to begin preparing the new UI in the background.

The urgent user interactions, like the visual feedback on the tab buttons, remain responsive. React will render the SlowComponent when the main thread is free, without blocking the rest of the application.

While startTransition prevents the UI from freezing, it can introduce a new UX challenge: a lack of immediate feedback.

The user clicks a button, and for a moment, nothing appears to happen while the transition is processed in the background. The isPending flag is the solution to this problem. It allows us to show a loading state the moment the transition begins

concurrent-rendering.jsx
import { useState, useTransition } from 'react';
import SlowComponent from './SlowComponent';
function App() {
const [tab, setTab] = useState<string>('about');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<button onClick={() => selectTab('about')}>About</button>
<button onClick={() => selectTab('posts')}>Posts</button>
<button onClick={() => selectTab('contact')}>Contact (Slow)</button>
</div>
{isPending && <p>Loading...</p>}
{tab === 'about' && <p>This is the About tab.</p>}
{tab === 'posts' && <p>This is the Posts tab.</p>}
{tab === 'contact' && <SlowComponent />}
</div>
);
}

In this revised example, when a transition starts, isPending becomes true.

This immediately triggers a re-render that shows a "Loading..." message and applies a semi-transparent style to the tab container.

This feedback is instant because the state update for isPending is part of an urgent render. This decouples the user's acknowledgment from the task's completion.

The application feels responsive because it immediately confirms the user's action, even while the heavy lifting of rendering the new content is deferred.

During this transition, React effectively maintains two parallel states.

The urgent parts of the UI (the loading indicator and opacity style) render with the new isPending state, while the transitional part of the UI (the tab content) continues to show the old state (tab is still its previous value) until the SlowComponent is ready.

Once the transition completes, React commits the new state for tab and sets isPending back to false in a single, atomic update, ensuring a consistent UI.

Practical patterns

Now that you have the understanding of what problem does useTransition solves and how to use it. You can use it to solve these problems


1. Filtering Large Datasets while keeping the UI responsive

import { useState, useTransition } from 'react';
import { generateUsers, User } from './data';
const users: User[] = generateUsers();
function App() {
const [searchTerm, setSearchTerm] = useState<string>('');
const [filteredUsers, setFilteredUsers] = useState<User[]>(users);
const [isPending, startTransition] = useTransition();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
// Urgent update: The input field value updates immediately.
setSearchTerm(value);
// Non-urgent update: The list filtering is deferred.
startTransition(() => {
setFilteredUsers(users.filter((user) => user.name.includes(value)));
});
};
return (
<div>
<input onChange={handleChange} value={searchTerm} type="text" placeholder="Type a name" />
{isPending && <div>Loading list...</div>}
{/* Render the filtered list, which might show stale data during the transition */}
</div>
);
}

Here setSearchTerm remains an urgent update, ensuring the input field is always responsive.

The expensive setFiltered operation is wrapped in startTransition, telling React to process it in the background. The isPending flag provides immediate feedback, informing the user that the list is being updated, even as they continue to type smoothly.


2. Non-Blocking Form Validations and Submissions

In complex forms, especially in enterprise applications, user input in one field might trigger complex validation logic or recalculations that affect other parts of the form. useTransition can prevent this logic from blocking the user's flow.

import { useState, useTransition } from 'react';
// A simulated expensive validation function
const validateUsername = (username: string): string => {
//...simulates a slow check, e.g., against a large local list or complex regex
if (username.length > 0 && username.length < 4) {
return "Username is too short.";
}
return "";
};
function ComplexForm() {
const [formData, setFormData] = useState({ username: '' });
const [validationMessage, setValidationMessage] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// Urgent: Update the form input field
setFormData(prevData => ({ ...prevData, [name]: value }));
// Non-urgent: Perform validation in the background
startTransition(() => {
const message = validateUsername(value);
setValidationMessage(message);
});
};
return (
<form>
<input name="username" value={formData.username} onChange={handleChange} />
{isPending && <p>Validating...</p>}
{!isPending && validationMessage && <p>{validationMessage}</p>}
</form>
);
}

3. Smooth and Interruptible SPA Navigation

In a Single-Page Application (SPA), navigating to a new route often triggers significant rendering work for the new page's components. useTransition is ideal for making these navigational changes feel smoother and more responsive.

import { useState, useTransition } from 'react';
import HomePage from './HomePage';
import ProfilePage from './ProfilePage'; // Assume this is a component that loads data
function AppRouter() {
const [page, setPage] = useState<string>('/');
const [isPending, startTransition] = useTransition();
function navigate(url: string) {
startTransition(() => {
setPage(url);
});
}
return (
<div>
<nav>
<a onClick={() => navigate('/')}>Home</a>
<a onClick={() => navigate('/profile')}>Profile</a>
</nav>
{isPending && <div>Loading page...</div>}
{page === '/' && <HomePage />}
{page === '/profile' && <ProfilePage />}
</div>
);
}

When the user clicks a navigation link, the navigate function wraps the setPage update in a transition.

This allows React to start rendering the next page in the background while keeping the current page fully interactive.

If the user quickly clicks another link before the first navigation is complete, React can abandon the first transition and prioritize the new one, ensuring the UI always responds to the latest user input.

SSR Streaming + useTransition hook

Before I talk about using the useTransition hook with Suspense and Server Side Streaming, let me quickly touch upon how we use Server Side Streaming with Suspense and why is it useful.

Prior to React v18, we had to serialize the whole React tree to HTML before sending it to the client where it would then get hydrated. Basically the users had to stare at a blank screen until the entire page got loaded.

But now from React v18, React now has the ability to generate and stream partial HTML from server to client.

This means that the client can start rendering and hydrating parts of the application as soon as they are available, without having to wait for the whole tree to be ready.

Let's see this with an example:

app/post/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { getQueryClient, trpc } from "@/trpc/server"
import { Suspense } from "react"
import { ErrorBoundary } from "react-error-boundary"
import { PostsTable } from "./post-table"
import { Navbar } from "./navbar"
import { PostsTableSkeleton } from "./post-table-skeleton"
export default async function Post() {
const queryClient = getQueryClient()
// Prefetch the initial, unsorted, and unfiltered list of posts
void queryClient.prefetchQuery(
trpc.posts.queryOptions({
search: "",
sortBy: "id",
sortOrder: "asc",
})
)
return (
<main>
<Navbar />
<HydrationBoundary state={dehydrate(queryClient)}>
<ErrorBoundary fallback={<div>Error occurred</div>}>
<Suspense fallback={<PostsTableSkeleton />}>
<PostsTable />
</Suspense>
</ErrorBoundary>
</HydrationBoundary>
</main>
)
}

In the PostsTable component, we use the useSuspenseQuery hook to get the prefetched data

app/post/post-table.tsx
export function PostsTable() {
const trpc = useTRPC()
// State for search and sort
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [sort, setSort] = useState<SortState>({
sortBy: "id",
sortOrder: "asc",
})
// Debounce the search input to avoid excessive API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search)
}, 500)
return () => clearTimeout(timer)
}, [search])
// Fetch data using our tRPC procedure and useSuspenseQuery
const { data: posts } = useSuspenseQuery(
trpc.posts.queryOptions({
search: debouncedSearch,
sortBy: sort.sortBy,
sortOrder: sort.sortOrder,
})
)
// Handler for changing the sort order
const handleSort = (column: SortState["sortBy"]) => {
setSort(currentSort => ({
sortBy: column,
sortOrder:
currentSort.sortBy === column && currentSort.sortOrder === "asc"
? "desc"
: "asc",
}))
}
return (
<div className="container mx-auto py-10">
{/* Render the posts table */}
</div>
)
}

Complete Code

Here the server prefetches data while simultaneously streaming an initial UI shell to the user. This shell contains static components and skeleton placeholders, so the user sees a meaningful layout immediately.

Once the data is ready, the final component is streamed to seamlessly replace the skeleton, ensuring a fast, non-blocking experience without a blank loading screen.

The problem

This works great on initial page load.

The problem occurs when you try searching or sorting the table.

Why are we seeing the fallback UI again?

Since our posts fetching depends on 2 states, the search state or the sorting state, any change in the state would trigger the post api call, and while we are waiting for the data, React will unmount the PostTable component and show the fallback component.

This messes up the UX, what we should do instead is show the stale data while the new data is being fetched. To do this we have a placeholdeData prop in the useQuery hook.

const {data: posts, error, isLoading} = useQuery(trpc.posts.queryOptions({
search: debouncedSearch,
sortBy: sort.sortBy,
sortOrder: sort.sortOrder,
}, {
placeholderData: (previousData) => previousData
}))

But useSuspenseQuery doesn't support placeholderData, we can use the useTransition hook to show the previous data instead of unmounting and showing a suspense fallback while fetching.

All we need to do is wrap the state updates in the startTransition function

// define useTransition
const [isPending, startTransition] = useTransition()
// wrap the state updates in useTransition
useEffect(() => {
const timer = setTimeout(() => {
// Wrap the state update that triggers the query in startTransition
startTransition(() => {
setDebouncedSearch(search)
})
}, 500)
return () => clearTimeout(timer)
}, [search])
const handleSort = (column: SortState["sortBy"]) => {
startTransition(() => {
setSort(currentSort => ({
sortBy: column,
sortOrder:
currentSort.sortBy === column && currentSort.sortOrder === "asc"
? "desc"
: "asc",
}))
})
}
// Use the isPending boolean to animate the loading while fetching new data
<Table className={isPending ? "opacity-70 animate-pulse" : ""}>

Why this works?

When the user types in the search box or clicks a column to sort, since state updates are wrapped in startTransition, it tells React that these updates are non-urgent. This allows React to keep the current UI (with the existing posts data) visible and interactive while it fetches the new data in the background. Instead of unmounting the PostsTable component and showing the fallback UI, React continues to display the current posts data until the new data is ready.

Conclusion

The core mental model for useTransition is that of a scheduler, not an optimizer. It does not make slow code run faster; it intelligently schedules when that code's effects (the UI updates) are rendered.

It allows developers to declaratively separate urgent user-facing updates from non-urgent background updates, ensuring that the application always prioritizes user interaction.

Best Practices (The "Do's")

  • DO use it for any state update that triggers a slow, complex, or computationally expensive re-render. Prime candidates are filtering, sorting, or rendering large datasets and complex visualizations.

  • DO always provide immediate user feedback using the isPending state. This is critical for good UX, as it acknowledges the user's action while the transition is processed.

  • DO combine it with <Suspense> for data fetching to prevent jarring content flashes on data refreshes, enabling a "show stale while revalidating" pattern.

  • DO architecturally identify "control/view" pairs in your UI. Keep updates to the controls urgent and wrap updates that re-render the views in transitions.

Anti-Patterns (The "Don'ts")

  • DON'T use it to control text inputs or other form elements that require immediate, synchronous feedback. These are urgent updates by nature.

  • DON'T wrap every state update in startTransition. Overuse can make an application feel unresponsive in a different way, as too many updates are deferred. Apply it strategically only where performance bottlenecks exist.

  • DON'T confuse it with debouncing. useTransition is for scheduling rendering, while debouncing is for limiting the frequency of side effects like API calls. Use the right tool for the job, or compose them for maximum effect.

  • DON'T use it as a substitute for fundamental component optimization. Before reaching for useTransition, ensure your components are already optimized where possible using techniques like React.memo, useMemo, useCallback, and list virtualization.

useTransition should be used to handle unavoidable rendering costs, not to mask inefficient code.

That's all for this blog. If you have any questions, feel free to reach out to me on my socials. 👋