React 19 Concurrent UI Patterns: 4 Code Patterns to Eliminate Input Lag with useTransition · useDeferredValue · useOptimistic
I remember the first time I encountered the concept of Concurrency when React 18 came out. Honestly, my first thought was "so what exactly changes in my code?" The opt-in approach felt abstract and hard to explain, so I let it slide for a while — but after the new APIs were refined in React 19 and I actually applied them to a real project, I finally got it: "ah, so this is the problem it solves."
This article uses TypeScript for all example code, and is written so that anyone who knows React basics (useState, component structure) can follow along. It should be especially helpful if you've been confused about when to use useTransition versus useDeferredValue.
TL;DR: Applying the 4 patterns covered in this article (
useTransition,useDeferredValue,useOptimistic,useTransition+<Suspense>) lets you solve search input lag, page transition flickering, and the need for immediate feedback without waiting for server responses. The core of React 19 concurrency patterns is keeping the UI responsive at all times — even during heavy computation — by separating urgent updates from non-urgent ones and scheduling them accordingly.
Core Concepts
The Problem with Synchronous Rendering: Why the UI Freezes
React's traditional rendering is synchronous and blocking. When state changes, React halts everything else while computing the result. Think about filtering a list of thousands of items — while the user is typing in a search box, React is busy computing the filter results, causing the input itself to feel sluggish.
That's exactly the problem concurrency patterns solve. With concurrency enabled, React can interrupt, pause, and resume rendering work. The moment a user presses a key, React briefly pauses the in-progress list rendering, processes the input update first, then resumes rendering the list when it has capacity.
One thing worth clarifying here: concurrency itself was already introduced as opt-in in React 18 with createRoot. React 19 is better understood as an extension of that, adding and refining new APIs like useOptimistic and Actions. A common misconception is "React 18 doesn't have concurrency" — but if your app is createRoot-based, it's already running on the concurrent renderer.
Concurrency is React's ability to evaluate the priority of multiple rendering tasks and process them in order of importance. Multiple tasks don't literally execute at the same time — rather, higher-priority tasks preempt lower-priority ones. Since JS is single-threaded and rendering can't actually be paused, React's scheduler implements this by breaking rendering into small chunks and yielding execution back to the browser.
Two Types of Updates: Urgent vs. Non-Urgent
This distinction is the fastest way to understand concurrency patterns.
| Type | Examples | Characteristic |
|---|---|---|
| Urgent updates | Key presses, clicks, touches | If not reflected immediately, the app feels broken |
| Non-urgent updates | Search result rendering, page transitions, chart updates | A slight delay is naturally acceptable |
React 19's concurrency APIs are tools for explicitly separating these two types.
Practical Application
Before diving into the code, here's a quick overview of the APIs covered in this section to help you follow along.
| API | What it controls | Primary use case |
|---|---|---|
useTransition / startTransition |
Priority of state updates | Search filtering, page transitions |
useDeferredValue |
When a value is consumed | Autocomplete, preview rendering |
useOptimistic |
Optimistic UI before server response | Like buttons, form submission |
<Suspense> |
Declarative async component handling | Data fetching, code splitting |
Choosing between
useTransitionanduseDeferredValue: If you can touch the code that directly updates the state, useuseTransition. If you can't control the update timing — such as with values coming from external components or props —useDeferredValueis the cleaner choice.
Example 1: Smooth Search Input — useTransition
This is a situation you frequently encounter in real projects. You need to filter thousands of items with every keystroke, but the input itself slows down. My first instinct was to solve this with debounce, but the concurrency pattern handles it far more naturally.
import { useState, useTransition } from 'react';
// filterHeavyData: a heavy function that synchronously filters an items array by query
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // urgent: input reflects immediately
startTransition(() => {
setResults(filterHeavyData(e.target.value)); // non-urgent: heavy filtering can yield
});
}
return (
<>
<input value={query} onChange={handleChange} placeholder="검색어를 입력하세요" />
{isPending ? <Spinner /> : <ResultList items={results} />}
</>
);
}| Code | Role |
|---|---|
setQuery(e.target.value) |
Urgent update — the input always responds immediately |
startTransition(() => ...) |
Marked non-urgent — tells React "this can be processed later" |
isPending |
Whether a transition is in progress — use for loading UI like spinners |
Unlike debounce, transitions have no artificial delay — they're processed naturally whenever the browser has capacity. The faster the user types, the more React discards previous filtering computations and recalculates with the latest input.
There are practical limits, though. If filterHeavyData is a purely synchronous function, this approach won't fully solve the problem of blocking the JS thread itself. For extremely CPU-bound work, it's worth also considering Web Workers.
One notable change in React 19: startTransition can now accept async functions directly. In React 18, you couldn't use await inside a transition callback — in React 19 you can. This makes the code much cleaner when you need to wrap server requests like form submissions in a transition.
Example 2: Keep Current Content During Page Transitions — useTransition + <Suspense>
After search inputs, pagination is the next most common pattern. Clicking "next" and watching the existing table disappear, a skeleton flicker, then new data appear — it's more disruptive than you might think. The concurrency pattern can make this feel much more natural.
function DataTable() {
const [page, setPage] = useState(1);
const [isPending, startTransition] = useTransition();
function goToNextPage() {
startTransition(() => {
setPage(p => p + 1);
});
}
return (
<Suspense fallback={<TableSkeleton />}>
{/* while loading the new page, show current content at reduced opacity */}
<div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.2s' }}>
{/* TableContent internally calls use(fetchPage(page)) to trigger Suspense */}
<TableContent page={page} />
</div>
<button onClick={goToNextPage} disabled={isPending}>
{isPending ? '로딩 중...' : '다음 페이지'}
</button>
</Suspense>
);
}Without startTransition, calling setPage causes React to switch to the <Suspense> fallback (skeleton) until <TableContent> is ready. With startTransition, React keeps the current content visible while preparing the new data in the background, then transitions all at once when ready. Instead of a flickering skeleton, the table gently fades — a far more natural loading experience.
Example 3: Instant Feedback Without Waiting for the Server — useOptimistic
For features like a like button — where "it needs to react immediately, but also needs to save to the server" — useOptimistic really shines. Previously you had to manually write local state, a loading flag, and try/catch rollback logic. useOptimistic cuts that boilerplate significantly.
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentState, increment: number) => currentState + increment
);
async function handleLike() {
addOptimisticLike(1); // immediately reflect +1 (optimistic update)
await likePost(postId); // server request in the background
}
return (
<form action={handleLike}>
<button type="submit">♥ {optimisticLikes}</button>
</form>
);
}| Action | Result |
|---|---|
| Button click | Count immediately +1 (no network wait) |
| Server request succeeds | initialLikes from the parent updates, confirming the value |
| Server request fails | If initialLikes prop hasn't changed, the original value is automatically restored |
A note on the rollback behavior: rather than thinking of it simply as "auto-rollback on failure," it's worth understanding more precisely. The optimistic state persists until the handleLike action completes, after which it reverts to the initialLikes prop value. Even if the server request fails, if the parent component sends down a new initialLikes, that new value becomes the confirmed one. For proper rollback, the structure must ensure the parent state is not updated when an error occurs.
Example 4: Autocomplete That Doesn't Chase Fast Typing — useDeferredValue
When you're consuming a value passed down as a prop from a parent component, you can't directly wrap it in startTransition. This is exactly where useDeferredValue fits — it delays the point at which the value is consumed.
import { memo, useDeferredValue } from 'react';
// without memo, SuggestionList re-renders with parent re-renders even when deferredValue hasn't changed
const SuggestionList = memo(function SuggestionList({ query }: { query: string }) {
return <ul>{/* render suggestion list based on query */}</ul>;
});
function AutoComplete({ value }: { value: string }) {
const deferredValue = useDeferredValue(value);
const isStale = value !== deferredValue; // whether a deferral is in progress
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<SuggestionList query={deferredValue} />
</div>
);
}Even as value changes rapidly, SuggestionList holds on to the previous result and only updates when React has capacity. For fast typists, intermediate keystrokes are skipped and the list only renders once for the final input.
It's important to note that the memo wrapper must not be omitted. Without it, even when deferredValue hasn't updated yet, SuggestionList will re-render alongside the parent re-render. The memo ensures SuggestionList only re-renders when deferredValue actually changes — which is what makes the deferral effect work. Think of useDeferredValue as always needing to be paired with memo to function correctly.
Pros and Cons
Advantages
| Item | Detail |
|---|---|
| Maintained UI responsiveness | Inputs and clicks respond immediately even during heavy rendering |
| Declarative async handling | Loading, error, and optimistic states handled without explicit flags |
| Automatic memoization | With React Compiler, no need to manually write useMemo or useCallback |
| Incremental adoption | Can be applied by adding just a few lines of startTransition to existing code |
Disadvantages and Caveats
| Item | Detail | Mitigation |
|---|---|---|
| Learning curve | Choosing between useTransition and useDeferredValue is confusing at first |
Clarify by whether you have direct control over the state update |
| Suspense overuse risk | Too many fine-grained Suspense boundaries can cause loading waterfalls | Design boundaries around meaningful UI units for the user |
| Object reference issues | Passing newly created references on every render to useDeferredValue nullifies the deferral |
Pass primitive values or useMemo-stabilized values |
| React Compiler requires separate setup | Upgrading to React 19 alone does not activate automatic memoization | Add babel-plugin-react-compiler to build config |
| Debugging complexity | Interruptible rendering can make render order harder to trace | Use the Concurrent Mode tab in React DevTools 5.x |
| Server/Client boundary design | Poor boundary design with Server Actions + client concurrency can degrade UX | Clearly separate Server/Client responsibilities at the design stage |
Waterfall loading: A phenomenon where, with nested Suspense boundaries, lower boundaries only begin fetching data after upper boundaries resolve. Requests that could run in parallel end up running sequentially, increasing total load time. It can be mitigated by hoisting data requests toward the top of the component tree or by kick-starting Promises early.
The Most Common Mistakes in Practice
-
Putting urgent updates inside
startTransition: Wrapping something that needs to react immediately — like an input'svaluestate — inside a transition will make typing feel delayed. The basic rule is to only apply transitions to heavy result processing, and keep input state outside. -
Passing objects or arrays directly to
useDeferredValue: If you pass a value that creates a new reference on every render, the deferral has no effect at all. It's important to pass primitive values (string, number) or values stabilized withuseMemo. -
Forgetting
memoon child components ofuseDeferredValue: Withoutmemo, child components will re-render with parent re-renders even whendeferredValuehasn't changed.useDeferredValuemust always be used together withmemoto work correctly.
Closing Thoughts
React 19 concurrency patterns are a systematic toolkit for building UIs that stay responsive to users even while doing heavy work. You don't need to learn everything at once — pick one specific situation where you notice sluggishness and try applying it there. The intuition will come quickly.
Three steps you can take right now:
-
Try applying
useTransitionin just one place in an existing project. Find a component that has both input and result rendering — like a search box or filter — wrap it with thestartTransition(() => setResults(...))pattern, and feel for yourself whether typing responsiveness improves. -
Open the Profiler's Concurrent Mode tab in React DevTools 5.x. Transition boundaries, Suspense boundaries, and priority lanes are all visualized, giving you an intuitive view of which updates are processed at which priority. It also helps you identify current bottlenecks before changing any code.
-
Consider whether to introduce React Compiler. After running
pnpm add -D babel-plugin-react-compiler eslint-plugin-react-compiler, you can scan your existing codebase witheslint-plugin-react-compilerto gauge what percentage the compiler can handle. Meta reported removing 2,300 lines of manual memoization code with an actual performance improvement — definitely worth evaluating for larger projects. That said, it does require build configuration changes, so it's best to first experience the responsiveness gains from steps 1 and 2 before tackling this at your own pace.
References
Essential reading:
- React v19 Official Release Notes | react.dev
- useTransition Official Reference | react.dev
- useDeferredValue Official Reference | react.dev
Further reading:
- React Compiler v1.0 Official Announcement | react.dev
- Suspense Official Reference | react.dev
- useOptimistic Official Reference | react.dev
- React 19 Concurrency Deep Dive: useTransition | DEV Community
- React 19 useDeferredValue Deep Dive | DEV Community
- React 19 useOptimistic Deep Dive | DEV Community
- From Freeze to Flow: React 2025 Concurrent Rendering | Medium
- Smooth Async Transitions in React 19 | AppSignal Blog
- Meta's React Compiler 1.0 Production Launch | InfoQ