Your app might be fast… but does it feel fast?
Picture this: you’re typing into a search box, and suddenly your app feels like it’s running on a potato computer. 🥔💻
Letters appear a split-second late. The dropdown lags. The fan sounds like it’s about to take off.
Technically, your app is still doing its job… but to the user, it feels sluggish and broken.
That’s the subtle killer of UX: not speed, but perceived speed.
And that’s where React 19’s concurrency features — startTransition
and useTransition
— step in.
Do they make your code faster? Nope.
Do they make your app feel smoother and more delightful? Oh yes.
By letting urgent updates (typing, clicks, drags) happen instantly while heavier work (filters, charts, big renders) runs “in the background,” concurrency makes your UI buttery-smooth.
This article is your roadmap to mastering them.
We’ll cover:
- Why concurrency matters
- The problem React is solving
- What transitions are
- How to use
startTransition
anduseTransition
- Practical usage patterns
- Common gotchas and mistakes
- And how to think about concurrency like a pro
By the end, you’ll not just know about concurrency — you’ll be comfortable using it in real apps.
📑 Table of Contents
Why Concurrency Matters + startTransition
- Your app might be fast… but does it feel fast?
- Why Concurrency Matters
- The Big Idea of Concurrency
- The Problem Without Concurrency
-
startTransition
useTransition
& Choosing the Right Tool
-
useTransition
:startTransition
with Superpowers -
Comparison:
startTransition
vsuseTransition
- Quick Mental Shortcut
- Putting It Together
Practical Patterns, Pitfalls & Wrap-Up
- Practical Patterns You’ll Actually Use
- Common Pitfalls & Gotchas
- Pro Tips for Concurrency
- Wrapping It Up 🎁
Why Concurrency Matters
Let’s face it: users are unforgiving.
If the UI lags even for half a second, they’ll start clicking again, refreshing the page, or assuming your app is buggy.
Here’s a classic scenario: you have a product search box. The user types quickly — but the list of results is huge, and filtering is expensive.
Suddenly:
- The input freezes.
- Keystrokes appear late.
- The dropdown lags behind by a word or two.
The app isn’t “slow” in the sense that it crashes or errors. But the experience feels broken.
That’s because React, by default, tries to handle everything at once:
- Update the input
- Filter thousands of items
- Rerender the UI
- Update the layout
And since React’s state updates are synchronous by default, your typing gets stuck behind all the heavy lifting.
Users don’t care that your filtering algorithm is correct. They care that typing feels instant.
The Big Idea of Concurrency
React 18 introduced a game-changing idea: updates don’t all have to be equal.
Think of it like a grocery store checkout:
- Someone with a full cart should take longer.
- Someone with a single soda should not wait behind them.
Concurrency lets React prioritize. You can say:
“Hey React, this thing here (like typing) must update right now.
This other thing (like recalculating a giant list)? Chill, do it when you can.”
That’s the whole mental model: urgent updates first, non-urgent updates later.
Urgent vs Non-Urgent
-
Urgent updates: tied directly to what the user is doing this instant.
- Typing in a box
- Clicking a button
- Dragging a slider
-
Non-urgent updates: heavier, can be delayed slightly without hurting UX.
- Filtering/searching large lists
- Sorting data
- Rendering heavy charts
- Loading new tab content
React’s concurrency system lets you mark updates as non-urgent, so urgent stuff flows through instantly.
Real-World Analogy
Imagine you’re at a coffee shop:
- You just want a plain black coffee.
- The customer ahead of you ordered a triple soy vanilla caramel oat milk latte with extra foam art.
A good barista doesn’t make you wait for the latte art. They pour your coffee and hand it over in 10 seconds.
That’s React concurrency in a nutshell:
Get the urgent order out now, finish the fancy one later.
The Problem Without Concurrency
Let’s see this problem in code.
Here’s a naïve product search implementation:
function ProductSearch({ products }) {
const [query, setQuery] = React.useState('');
const filtered = products.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
<ul>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
Looks fine, right?
But try it with 5,000 products and type quickly:
- The input lags.
- Letters appear late.
- CPU usage spikes.
Why? Because React is recalculating the entire filtered
list every single keystroke, blocking the urgent input update.
Enter startTransition
Here’s where concurrency comes in.
startTransition
is a way of telling React:
“This state update is not urgent. Don’t block urgent stuff for it.”
Syntax
import { startTransition } from 'react';
startTransition(() => {
// Non-urgent state updates go here
});
Everything inside that callback becomes a transition — React can delay it, pause it, or restart it without blocking urgent updates.
Example: Fixing Search
Let’s fix our sluggish search:
import { startTransition, useState } from 'react';
function ProductSearch({ products }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(products);
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent update — input must feel instant
startTransition(() => {
// non-urgent — heavy filtering
setFiltered(
products.filter((p) =>
p.name.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
<ul>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
🛠️ What’s Happening Here?
setQuery(value)
runs outside the transition
- This is the urgent update.
- It makes the input box stay in sync with the user’s keystrokes.
- No matter how heavy the filtering is, the input itself stays snappy.
- The filtering runs inside
startTransition
- This is the non-urgent update.
- React now knows it can delay or pause this work if something more urgent happens (like another keystroke).
- If the user types quickly, React will cancel the half-done filtering for “ap” and move straight to filtering for “app”.
- User experience feels smooth
- Letters appear instantly in the input box.
- Results update as soon as React can catch up.
- No “typing through molasses” feeling.
🚦 Why This Feels Faster (Even Though It’s Not)
The actual filtering work hasn’t magically sped up — you’re still filtering the whole array.
But React schedules it smarter:
- Urgent updates (typing) = 🚗 Fast lane
- Non-urgent updates (filtering) = 🚚 Slow lane
The key is perceived performance: users don’t care if results take 200ms — as long as typing itself feels instant.
Another Example: Sorting
Imagine a “Sort by Price” button. Sorting 20,000 products might freeze the UI for a second.
With startTransition
, the click feels instant while sorting happens in the background:
function ProductSorter({ products }) {
const [list, setList] = useState(products);
function sortByPrice() {
startTransition(() => {
setList([...list].sort((a, b) => a.price - b.price));
});
}
return (
<div>
<button onClick={sortByPrice}>Sort by price</button>
<ul>
{list.map(p => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
</div>
);
}
Result: the button click feels responsive, React schedules the sort as non-urgent, and users don’t feel frozen out.
Where You Can Use startTransition
- Inside components (event handlers, effects, etc.)
- Inside functions outside React (like updating a global store)
- Even inside async functions (wrap expensive state updates)
It’s flexible — anywhere you need to say “this isn’t urgent.”
Limitations of startTransition
- It’s fire-and-forget: you can’t know if React is still working on the update.
- It doesn’t let you show “loading” or “pending” indicators.
- It doesn’t make code faster — it just prioritizes scheduling.
For feedback in the UI, we’ll need its big sibling: useTransition
.
useTransition
: startTransition
with Superpowers
So far, startTransition
gave us smoother typing and snappy button clicks.
But it’s kind of a fire-and-forget tool.
You tell React: “treat this update as non-urgent,” and that’s it.
But what if you want the UI to know when a transition is in progress?
For example:
- Show a spinner while results are recalculating.
- Gray out a button while a heavy update runs.
- Display “Loading tab…” when switching.
That’s exactly what useTransition
gives you.
Syntax
const [isPending, startTransition] = useTransition();
It gives you two things:
-
startTransition
: works exactly like the standalone function. -
isPending
: a boolean that tells you whether the transition is still happening.
Think of it like a progress light:
- 🟢
isPending = false
: all good, no background work. - 🔴
isPending = true
: React is crunching in the background.
Example: Search with a Loading Indicator
Let’s upgrade our product search to show feedback when filtering runs:
import { useTransition, useState } from 'react';
function ProductSearch({ products }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(products);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent — keep typing snappy
startTransition(() => {
// non-urgent — filtering
setFiltered(
products.filter((p) =>
p.name.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{isPending && <p>Filtering results…</p>}
<ul>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
🛠️ What’s Going On Here?
- Urgent update:
setQuery(value)
- Just like before, this keeps the input box responsive.
- Even if filtering takes a while, keystrokes appear immediately.
- Non-urgent update:
setFiltered(...)
insidestartTransition
- React treats this as “can wait a bit.”
- If you type fast, React will skip unfinished work and jump to the latest query.
- New piece:
isPending
- While React is working on the transition,
isPending
istrue
. - Once the filtering finishes,
isPending
flips back tofalse
. - That means you can show a loading message or spinner without manually tracking it.
👀 Why This Matters
Without isPending
, the user might wonder:
“Did my filter actually run? Is the app stuck?”
With isPending
, you can immediately provide feedback:
- “Filtering results…”
- A spinner or skeleton UI
- Dim the results list while work is in progress
This feedback makes the app feel responsive and alive, even if results take a moment to catch up.
🚦 UX Breakdown
- Typing: Always instant.
- Results: May take a bit, but user sees a “Filtering…” message.
- Overall feel: Smooth + transparent.
Users forgive delays if they’re acknowledged.
It’s the difference between:
- Silence while waiting (confusing 🤔)
- vs. a quick “Loading results…” (reassuring ✅).
👉 That’s why useTransition
is the better choice when your UI needs to show progress, while startTransition
is fine for “fire and forget” updates.
Another Example: Tab Switching with Heavy Content
Tabs are a classic place where concurrency shines.
You want the tab header to switch instantly (urgent).
But the tab content might be slow to load/render (non-urgent).
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const [content, setContent] = useState(tabs[0].content);
const [isPending, startTransition] = useTransition();
function selectTab(index) {
setActiveTab(index); // urgent — switch header instantly
startTransition(() => {
// simulate heavy content
setContent(tabs[index].content);
});
}
return (
<div>
<div className="tab-headers">
{tabs.map((tab, i) => (
<button key={i} onClick={() => selectTab(i)}>
{tab.title}
</button>
))}
</div>
{isPending && <p>Loading tab content…</p>}
<div className="tab-content">{content}</div>
</div>
);
}
Now:
- The tab switch feels instant.
- The “Loading…” message appears only if the content takes time.
- No frozen UI.
🌐 Example: Async Work (API + useTransition
)
Imagine we’re building a search box that fetches results from an API. Without transitions, every keystroke could trigger a request and lock up the UI. With useTransition
, we can keep typing instant while React handles the async fetch in the background.
import { useState, useTransition } from 'react';
function SearchUsers() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
async function fetchUsers(value) {
const response = await fetch(`/api/users?q=${value}`);
const data = await response.json();
setResults(data);
}
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent — keeps input snappy
startTransition(() => {
// non-urgent — async fetch + update results
fetchUsers(value);
});
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search users..." />
{isPending && <p>Searching users…</p>}
<ul>
{results.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
🛠️ Step-by-Step Breakdown
- Urgent update —
setQuery(value)
- Keeps the text input fully responsive.
- Typing feels instant, no matter how slow the API is.
- Non-urgent update — inside
startTransition
- The API call (
fetchUsers
) andsetResults
run in the transition. - If the user keeps typing, React can pause/skip outdated requests.
isPending
while fetching
- As soon as the transition starts,
isPending
flips totrue
. - That’s our chance to show “Searching users…”.
- Once results arrive and React finishes rendering them,
isPending
flips back tofalse
.
🚧 What Could Go Wrong?
- Stale results / race conditions Suppose the user types “Al” → request 1 starts. Then types “Alice” → request 2 starts. If request 1 finishes after request 2, it could overwrite the correct results.
👉 Fix: Use an AbortController
or track request IDs so only the latest response updates state.
Example fix with AbortController
:
let controller;
async function fetchUsers(value) {
if (controller) controller.abort(); // cancel old request
controller = new AbortController();
const response = await fetch(`/api/users?q=${value}`, {
signal: controller.signal,
});
const data = await response.json();
setResults(data);
}
👀 Why This Matters
Without concurrency:
- Typing into the input feels sluggish.
- Multiple overlapping requests can lock up the UI.
With useTransition
:
- Input stays buttery smooth.
- Users see a clear “Searching…” state.
- Old requests can be canceled or ignored, so results stay correct.
👉 That’s why useTransition
is especially handy when mixing async APIs + heavy state updates. You keep the UI interactive while React juggles the background work.
Comparison: startTransition
vs useTransition
Both tools do the same core job: mark updates as non-urgent.
But they’re used in different situations.
startTransition
(function)
- Can be used anywhere (inside or outside components).
- Fire-and-forget: no pending state.
- Great when you don’t care about loading feedback.
Example:
import { startTransition } from 'react';
function updateGlobalState(query) {
startTransition(() => {
globalStore.setState({ query });
});
}
useTransition
(hook)
- Can only be used inside components.
- Gives you
isPending
to show loading feedback. - Perfect for UI-driven state changes.
Example:
const [isPending, startTransition] = useTransition();
startTransition(() => {
setData(expensiveCalculation());
});
Quick Mental Shortcut
- Need pending state in the UI? 👉
useTransition
. - Just want to deprioritize heavy work? 👉
startTransition
.
Putting It Together
At this point, you know:
-
startTransition
: the lightweight “mark this as non-urgent” tool. -
useTransition
: the heavyweight “mark it + track it” tool.
These are the building blocks for buttery-smooth UX.
Next, we’ll see real-world usage patterns — where transitions really shine, how to combine them with Suspense, and what pitfalls to avoid.
Practical Patterns You’ll Actually Use
Okay — so far we’ve seen toy examples like searching and tab switching.
But where do concurrency features really shine in production apps?
Let’s dig into some real-world patterns.
1. Pagination Without Rage-Clicks
You’ve seen it: big tables or long lists where clicking “Next Page” freezes the UI.
That’s because React is rendering hundreds of rows at once.
With transitions, you can make the page number update instantly (urgent), while rendering the heavy page content happens in the background (non-urgent).
function PaginatedList({ pages }) {
const [page, setPage] = useState(0);
const [items, setItems] = useState(pages[0]);
const [isPending, startTransition] = useTransition();
function handlePageChange(newPage) {
setPage(newPage); // urgent — button feels instant
startTransition(() => {
// non-urgent — render heavy new page
setItems(pages[newPage]);
});
}
return (
<div>
<PaginationControls current={page} onPageChange={handlePageChange} />
{isPending && <p>Loading page…</p>}
<ItemList items={items} />
</div>
);
}
Result:
- The page indicator updates immediately.
- The list loads smoothly, without freezing clicks.
2. Rendering Expensive Charts
Charts can involve thousands of data points.
Without transitions, filtering or updating chart data can lock up interactions.
With useTransition
, the filter UI stays instant, while React processes the heavy chart in the background:
function Dashboard({ data }) {
const [chartData, setChartData] = useState([]);
const [isPending, startTransition] = useTransition();
function applyFilters(filters) {
startTransition(() => {
setChartData(processData(data, filters)); // heavy work
});
}
return (
<>
<Filters onChange={applyFilters} />
{isPending && <p>Updating chart…</p>}
<Chart data={chartData} />
</>
);
}
Users can keep clicking around while the chart updates asynchronously.
3. Tabs with Heavy Content
Switching between tabs with complex content (tables, forms, charts) can feel sluggish.
Concurrency makes tab headers instant, while the heavy tab body renders non-urgently.
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const [isPending, startTransition] = useTransition();
function selectTab(index) {
setActiveTab(index); // urgent
startTransition(() => {
loadTabData(index); // non-urgent
});
}
return (
<>
<TabHeaders active={activeTab} onSelect={selectTab} />
{isPending && <p>Loading tab…</p>}
<TabContent index={activeTab} />
</>
);
}
Tabs feel instant, even if their content takes a while.
4. Server Components + Transitions
When fetching server-rendered components, transitions prevent UI blocking while waiting for streamed HTML.
function ServerRenderedList({ query }) {
const [data, setData] = useState(null);
const [isPending, startTransition] = useTransition();
function updateQuery(newQuery) {
startTransition(async () => {
const response = await fetch(`/rsc-list?q=${newQuery}`);
setData(await response.json());
});
}
return (
<>
<SearchBox onChange={updateQuery} />
{isPending && <p>Loading results…</p>}
<List data={data} />
</>
);
}
The search box stays responsive while the server work happens.
5. Combining with Suspense
Transitions and Suspense are a power combo.
- Transitions: make urgent vs non-urgent updates clear.
- Suspense: shows a fallback while waiting for async work.
function Profile({ userId }) {
const [isPending, startTransition] = useTransition();
const [id, setId] = useState(userId);
function changeUser(newId) {
startTransition(() => {
setId(newId);
});
}
return (
<>
<UserSelector onChange={changeUser} />
{isPending && <p>Fetching profile…</p>}
<Suspense fallback={<p>Loading profile…</p>}>
<UserProfile userId={id} />
</Suspense>
</>
);
}
Result:
- The UI updates instantly on user change.
- Suspense gracefully shows loading states for async boundaries.
Common Pitfalls & Gotchas
Concurrency is powerful, but it’s not magic.
Here are the most common mistakes developers make — and how to avoid them.
1. Wrapping Everything in a Transition
Bad idea 🚫. If you wrap all updates in startTransition
, React thinks nothing is urgent.
That leads to a sluggish app where even clicks and typing are delayed.
Wrong:
startTransition(() => {
setQuery('abc'); // urgent — should NOT be here
setResults(expensiveFilter());
});
Right:
setQuery('abc'); // urgent
startTransition(() => {
setResults(expensiveFilter());
});
👉 Rule: keep urgent updates out of transitions.
2. Expecting Transitions to Speed Up Code
Transitions don’t make code faster.
They only reschedule updates so urgent stuff runs first.
If your filtering logic is super slow, you still need to optimize with:
- Memoization
- Virtualization
- Efficient algorithms
Transitions just make the UX smooth while the heavy work happens.
3. Race Conditions
Transitions can be interrupted. That means results can come back out of order.
Example:
- User types “ap” → transition starts.
- Then they type “app” → another transition starts.
- If “ap” finishes after “app”, stale results overwrite new ones.
👉 Fix: cancel stale work (AbortController
) or track request IDs.
4. Forgetting isPending
Scope
isPending
only tracks the transition in the current component.
If you’re doing async work outside React, you’ll still need your own loading flags.
5. Debugging Headaches
Sometimes UI feels stale because a transition deferred updates.
Pro tip: sprinkle console.log
with isPending
to see when transitions are active.
Pro Tips for Concurrency
- ✅ Use
startTransition
for utility/state updates outside components. - ✅ Use
useTransition
inside components when the UI needs feedback. - ✅ Always separate urgent vs non-urgent updates.
- ✅ Combine with Suspense for async work.
- ❌ Don’t rely on transitions for performance fixes — they’re about UX, not raw speed.
Wrapping It Up 🎁
So, does concurrency make your app faster?
Nope. Sorry.
But does it make your app feel faster? Absolutely.
React 19’s concurrency tools — startTransition
and useTransition
— are like giving React a traffic cop:
- Urgent updates (typing, clicks, selections) zoom through. 🚦
- Non-urgent updates (filtering, rendering, fetching) wait their turn.
The result:
- No more laggy search boxes.
- No frozen buttons.
- Tabs and charts that feel instant.
- Apps that feel smooth and professional, even under heavy workloads.
Key Takeaways
- Use transitions to separate urgent from non-urgent updates.
-
startTransition
: use anywhere, no pending state. -
useTransition
: use in components, with pending feedback. - Don’t wrap urgent updates.
- Handle race conditions carefully.
One-Liner Mental Model
Transitions don’t make your app faster — they make it feel faster by getting out of the way of urgent updates.
Next time your app feels sluggish during big updates, you’ll know exactly what to tell React:
“Do this now, do that later.” ✨
👉 Coming up next: React 19 useDeferredValue
Deep Dive — How to Keep Your UI Smooth When Things Get Heavy
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome