React 19 Concurrency Deep Dive — Mastering `useTransition` and `startTransition` for Smoother UIs
Ali Aslam

Ali Aslam @a1guy

About: https://www.linkedin.com/in/maliaslam/

Joined:
Aug 9, 2025

React 19 Concurrency Deep Dive — Mastering `useTransition` and `startTransition` for Smoother UIs

Publish Date: Aug 18
0 0

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 and useTransition
  • 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


useTransition & Choosing the Right Tool


Practical Patterns, Pitfalls & Wrap-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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🛠️ What’s Happening Here?

  1. 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.
  1. 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”.
  1. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🛠️ What’s Going On Here?

  1. Urgent update: setQuery(value)
  • Just like before, this keeps the input box responsive.
  • Even if filtering takes a while, keystrokes appear immediately.
  1. Non-urgent update: setFiltered(...) inside startTransition
  • React treats this as “can wait a bit.”
  • If you type fast, React will skip unfinished work and jump to the latest query.
  1. New piece: isPending
  • While React is working on the transition, isPending is true.
  • Once the filtering finishes, isPending flips back to false.
  • 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🛠️ Step-by-Step Breakdown

  1. Urgent update — setQuery(value)
  • Keeps the text input fully responsive.
  • Typing feels instant, no matter how slow the API is.
  1. Non-urgent update — inside startTransition
  • The API call (fetchUsers) and setResults run in the transition.
  • If the user keeps typing, React can pause/skip outdated requests.
  1. isPending while fetching
  • As soon as the transition starts, isPending flips to true.
  • That’s our chance to show “Searching users…”.
  • Once results arrive and React finishes rendering them, isPending flips back to false.

🚧 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);
}
Enter fullscreen mode Exit fullscreen mode

👀 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 });
  });
}
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

Right:

setQuery('abc'); // urgent
startTransition(() => {
  setResults(expensiveFilter());
});
Enter fullscreen mode Exit fullscreen mode

👉 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

Comments 0 total

    Add comment