Transitions
Keep typing and clicking fast in React with useTransition and startTransition: urgent and non-urgent updates, isPending, async actions, and Suspense.
- 01Tell urgent updates from transitionsSee why a slow render makes typing lag, and mark the slow part as a transition so the box stays fast.
- 02Use useTransition and startTransitionShow a pending state with isPending, keep the urgent render small, and run async actions in a transition.
- 03Know what transitions do and don't doKeep old content on screen while new content suspends, and reach for a debounce or a cache when the cost is the network.
A search box that can't keep up
A tea shop lists 120 teas, and a search box marks the ones that match what you type. Each row is slow on purpose: in the ProductList.jsx tab, TeaRow loops for 1 millisecond on lines 5 to 8, as a row that does real work would. Click in the box under the code and type green quickly.
import { useState } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. export default function App() { const [query, setQuery] = useState(""); return ( <> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search teas" /> <ProductList query={query} /> </> );}Line 6 keeps one piece of state, query. Line 11 sets it on every key, and line 14 hands it to the list. So every key renders all 120 rows, twice in development, because StrictMode calls each component twice: about a quarter of a second of work. Until that work is done, the box can't show your key. The letters appear late, one by one, behind your fingers. The console says why: Render the list for "g", Show the list for "g", and the same pair for every letter you typed, one after another.
Here is the same page with one change: the list's update is marked as a transition.
import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. function SearchBox({ onSearch }) { const [text, setText] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { onSearch(event.target.value); }); } return ( <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> );} export default function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox onSearch={setQuery} /> <ProductList query={query} /> </> );}Type green quickly again. Every letter shows in the box at once, Updating… appears next to it, and the marks catch up when you pause. Line 10 shows your key right away. Lines 11 to 13 wrap the list's update in startTransition, which tells React: this can wait, and it can be interrupted. The next sections take it line by line.
At a good café, nobody at the counter waits for a cake to finish baking before their order is taken. The kitchen's long jobs happen in between customers. If you change your mind, the half-made cake is dropped, and the new one is started.
- In real life: Taking your order at the counter, at once
- In React: An urgent update: the box shows your key
- In real life: Baking the cake in the kitchen, which takes a while
- In React: A transition: rendering the slow list
- In real life: The cook stops kneading to serve a new customer
- In React: React pauses the transition for your next key
- In real life: You change your order, and the half-made cake is dropped
- In React: React throws away the unfinished render and starts again with the new text
- In real life: A sign saying your order is on its way
- In React:
isPending, while the transition isn't shown yet
A transition is a state update that React may render in the background. Urgent updates, like typing, go first and show at once. A transition renders in small pieces, can be interrupted and thrown away, and keeps what's on screen until it's ready. useTransition starts one and tells you, with isPending, that it's on its way.
Urgent updates and transitions
Every update you've made so far was urgent. React renders an urgent update from the first component to the last without stopping. Your JavaScript and React's rendering share one main thread with the browser, so while React renders, the browser can't draw the page or handle your next key. For a quick render, nobody notices. For 120 slow rows, the page freezes for a moment on every key.
A transition is an update you mark as not urgent. React renders it in small pieces. Between components, it checks whether it has used up its slice of time, and if so, it hands the main thread back, so the browser can draw and handle your next key. Then it picks up where it stopped. If that key made an urgent update, React throws the unfinished transition work away, renders the urgent update first, and then starts the transition again with the newest state. Here are the two loops, from React 19.3.1's own code:
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-type] flow doesn't know that shouldYield() is side-effect free
performUnitOfWork(workInProgress);
}
}Lines 1 to 6 are workLoopSync, used for urgent updates. Line 4 renders one unit of work, one component or element, and line 3 keeps going until there are none left. Lines 8 to 14 are workLoopConcurrentByScheduler, used for transitions. Line 10 asks shouldYield() before each unit: React's scheduler says yes after about 5 milliseconds, and React hands the main thread back to the browser. You don't need these names to use transitions. Later, The work loop reads this file, and Lanes and Concurrent rendering, Suspense and transitions inside show how React marks a transition and throws its unfinished work away.
How to read the table: each row is one question, answered for an urgent update and for a transition.
| Question | Urgent update | Transition |
|---|---|---|
| How does React render it? | In one go, to the end, before the browser can do anything else. | In small pieces, with gaps where the browser can handle your next key or click. |
| Can another update stop it? | No. It always finishes. | Yes. An urgent update goes first, and the unfinished render is thrown away. |
| What does the screen show meanwhile? | Nothing changes, and the page doesn't respond until it's done. | The old screen, which still responds. isPending is true. |
| What if a component suspends? | The nearest Suspense fallback replaces what was there. | What was already on screen stays until the new content is ready. |
| What is it for? | What your hand is doing: typing, ticking, pressing. | What follows from it: results, a new tab, a new page. |
The playground makes the difference measurable. It runs the same kind of search with the React on this page, and times every key you type: how long until a frame showed it in the box, and how long until a frame showed the list for it.
onSearch(value)- Green tea no. 1
- Black tea no. 1
- Mint tea no. 1
- Jasmine tea no. 1
- Earl Grey tea no. 1
- Chai tea no. 1
- Oolong tea no. 1
- Lemon tea no. 1
- Ginger tea no. 1
- Rooibos tea no. 1
- Green tea no. 2
- Black tea no. 2
- Mint tea no. 2
- Jasmine tea no. 2
- Earl Grey tea no. 2
- Chai tea no. 2
- Oolong tea no. 2
- Lemon tea no. 2
- Ginger tea no. 2
- Rooibos tea no. 2
- Green tea no. 3
- Black tea no. 3
- Mint tea no. 3
- Jasmine tea no. 3
- Earl Grey tea no. 3
- Chai tea no. 3
- Oolong tea no. 3
- Lemon tea no. 3
- Ginger tea no. 3
- Rooibos tea no. 3
- Green tea no. 4
- Black tea no. 4
- Mint tea no. 4
- Jasmine tea no. 4
- Earl Grey tea no. 4
- Chai tea no. 4
- Oolong tea no. 4
- Lemon tea no. 4
- Ginger tea no. 4
- Rooibos tea no. 4
- Green tea no. 5
- Black tea no. 5
- Mint tea no. 5
- Jasmine tea no. 5
- Earl Grey tea no. 5
- Chai tea no. 5
- Oolong tea no. 5
- Lemon tea no. 5
- Ginger tea no. 5
- Rooibos tea no. 5
- Green tea no. 6
- Black tea no. 6
- Mint tea no. 6
- Jasmine tea no. 6
- Earl Grey tea no. 6
- Chai tea no. 6
- Oolong tea no. 6
- Lemon tea no. 6
- Ginger tea no. 6
- Rooibos tea no. 6
- Green tea no. 7
- Black tea no. 7
- Mint tea no. 7
- Jasmine tea no. 7
- Earl Grey tea no. 7
- Chai tea no. 7
- Oolong tea no. 7
- Lemon tea no. 7
- Ginger tea no. 7
- Rooibos tea no. 7
- Green tea no. 8
- Black tea no. 8
- Mint tea no. 8
- Jasmine tea no. 8
- Earl Grey tea no. 8
- Chai tea no. 8
- Oolong tea no. 8
- Lemon tea no. 8
- Ginger tea no. 8
- Rooibos tea no. 8
- Green tea no. 9
- Black tea no. 9
- Mint tea no. 9
- Jasmine tea no. 9
- Earl Grey tea no. 9
- Chai tea no. 9
- Oolong tea no. 9
- Lemon tea no. 9
- Ginger tea no. 9
- Rooibos tea no. 9
- Green tea no. 10
- Black tea no. 10
- Mint tea no. 10
- Jasmine tea no. 10
- Earl Grey tea no. 10
- Chai tea no. 10
- Oolong tea no. 10
- Lemon tea no. 10
- Ginger tea no. 10
- Rooibos tea no. 10
- Green tea no. 11
- Black tea no. 11
- Mint tea no. 11
- Jasmine tea no. 11
- Earl Grey tea no. 11
- Chai tea no. 11
- Oolong tea no. 11
- Lemon tea no. 11
- Ginger tea no. 11
- Rooibos tea no. 11
- Green tea no. 12
- Black tea no. 12
- Mint tea no. 12
- Jasmine tea no. 12
- Earl Grey tea no. 12
- Chai tea no. 12
- Oolong tea no. 12
- Lemon tea no. 12
- Ginger tea no. 12
- Rooibos tea no. 12
- Green tea no. 13
- Black tea no. 13
- Mint tea no. 13
- Jasmine tea no. 13
- Earl Grey tea no. 13
- Chai tea no. 13
- Oolong tea no. 13
- Lemon tea no. 13
- Ginger tea no. 13
- Rooibos tea no. 13
- Green tea no. 14
- Black tea no. 14
- Mint tea no. 14
- Jasmine tea no. 14
- Earl Grey tea no. 14
- Chai tea no. 14
- Oolong tea no. 14
- Lemon tea no. 14
- Ginger tea no. 14
- Rooibos tea no. 14
- Green tea no. 15
- Black tea no. 15
- Mint tea no. 15
- Jasmine tea no. 15
- Earl Grey tea no. 15
- Chai tea no. 15
- Oolong tea no. 15
- Lemon tea no. 15
- Ginger tea no. 15
- Rooibos tea no. 15
0 keysNo keys yet. Type into the box.
Type a word quickly, like green, into the box. Then switch modes and type it again.
With Plain update, type green quickly: every box bar is long, and every key's list was shown, one after another, while your next keys waited. Choose Transition and type it again: the box bars shrink to a frame or two, and some keys say list: not shown. React started their lists and threw them away when you typed again.
useTransition, line by line
Here is the search with a transition again, now read line by line.
import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. function SearchBox({ onSearch }) { const [text, setText] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { onSearch(event.target.value); }); } return ( <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> );} export default function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox onSearch={setQuery} /> <ProductList query={query} /> </> );}- Line 7 calls
useTransition(). LikeuseState, it's a hook, so it goes at the top of a component. It takes nothing and gives back two things, which line 7 names with array destructuring:isPending, a boolean, andstartTransition, a function. - Line 10 sets
text, the box's own value. That's an urgent update, so the box shows your key at once. - Lines 11 to 13 call
startTransitionwith a function. React runs that function right away, and marks every state update it makes as a transition. Line 12 callsonSearch, which issetQueryfromApp(line 28), so the newquery, and the slow list it causes, become the transition. - Line 19 shows
Updating…whileisPendingistrue: from the key until the new list is on screen.
So one key makes two renders, and you can see both in the console:
isPending to true, with the old list. The transition renders the new list, and isPending goes back to false when it's shown.The first, urgent render only involves SearchBox: its text changed, and useTransition set its isPending to true. App's query hasn't changed yet, so React doesn't render App or the list. The second render is the transition: App with the new query, the slow list, and SearchBox again with isPending back to false.
Now type green quickly and read the console. You see Render the list for "g", Render the list for "gr", and so on, but only one Show the list, for the last text. React started the list for each letter, and dropped it when the next letter came:
"g" was half done when you typed r. React dropped it, showed gr in the box, and started the list again for "gr". Only the newest list is ever shown.There's one trap. The urgent render must stay small, or the transition can't help. Here the transition is right, but both pieces of state live in App:
import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. export default function App() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { setQuery(event.target.value); }); } return ( <> <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> <ProductList query={query} /> </> );}Type in it: the box lags as badly as the first example. Line 11 updates App's text, so the urgent render renders App, and App renders ProductList on line 23, all 120 slow rows, with the old query. The console says it: Render the list for "" after each key. That's why the box's state lives in its own SearchBox component in the fixed version: the urgent render touches only the box. (Another fix is to tell React to skip the list when its props haven't changed, with memo, which memo, useMemo and useCallback covers.)
And why not put the box's own value in the transition too, and keep one component?
function handleChange(event) {
startTransition(() => {
setText(event.target.value); // the box's own value: wrong
onSearch(event.target.value);
});
}Line 3 makes the box's value wait for the transition. But the browser has already put your key in the box, and React makes the box show its state, which is still the old text. So the key disappears until the transition is done, and fast typing loses letters. React's documentation says it plainly: a transition can't control a text box. Keep the box urgent, and put what follows from it in the transition. (The next lesson, Deferred values, shows another way, with one piece of state.)
Tabs, and startTransition without the hook
Transitions aren't only for typing. Here a product page has three tabs, and the reviews tab renders 120 slow reviews (the Reviews.jsx tab). Each tab button starts its own transition:
import { useState, useTransition } from "react";import Reviews from "./Reviews.jsx";// The page links the styles.css tab for you. function TabButton({ children, isActive, action }) { const [isPending, startTransition] = useTransition(); if (isActive) return <b>{children}</b>; if (isPending) return <b className="pending">{children}…</b>; return ( <button onClick={() => { startTransition(() => { action(); }); }} > {children} </button> );} export default function ProductTabs() { const [tab, setTab] = useState("details"); return ( <> <nav> <TabButton isActive={tab === "details"} action={() => setTab("details")}>Details</TabButton> <TabButton isActive={tab === "reviews"} action={() => setTab("reviews")}>Reviews</TabButton> <TabButton isActive={tab === "shipping"} action={() => setTab("shipping")}>Shipping</TabButton> </nav> {tab === "details" && <p>Green tea, from the hills of Darjeeling.</p>} {tab === "reviews" && <Reviews />} {tab === "shipping" && <p>Ships in 2 days.</p>} </> );}TabButton calls useTransition on line 6. Its click, lines 11 to 15, calls the action prop inside startTransition, so the parent's setTab becomes a transition. React's documentation suggests naming a prop like this action, or ending its name in Action, so readers know it runs in a transition. While it's pending, line 8 shows the button as Reviews…. Click Reviews, then Shipping straight away: the page answers both clicks, and you land on Shipping without waiting for the reviews. Without the transition (exercise 4 starts that way), clicking Reviews freezes the page for about a quarter of a second, and your click on Shipping waits.
useTransition is a hook, so only components and other hooks can call it. Code elsewhere, like a router or a store, can import startTransition from react itself. It works the same way, but there's no isPending:
import { startTransition, useState } from "react";import Reviews from "./Reviews.jsx";// The page links the styles.css tab for you. export default function ProductTabs() { const [tab, setTab] = useState("details"); function select(next) { startTransition(() => { setTab(next); }); } return ( <> <nav> <button onClick={() => select("details")}>Details</button> <button onClick={() => select("reviews")}>Reviews</button> <button onClick={() => select("shipping")}>Shipping</button> </nav> {tab === "details" && <p>Green tea, from the hills of Darjeeling.</p>} {tab === "reviews" && <Reviews />} {tab === "shipping" && <p>Ships in 2 days.</p>} </> );}Line 1 imports it, and lines 8 to 12 switch tabs inside it. Click Reviews, then Shipping: the page doesn't freeze, but nothing shows that the reviews are on their way, because there's no isPending. Use the hook in components, and this one where a hook can't go.
Two rules about the function you pass. First, React calls it at once, not later. Click the button and read the console:
import { startTransition, useState } from "react"; export default function App() { const [tab, setTab] = useState("details"); function showReviews() { console.log(1); startTransition(() => { console.log(2); setTab("reviews"); }); console.log(3); } return <button onClick={showReviews}>Tab: {tab}</button>;}The console says 1, 2, 3: line 7, then line 9 inside the function, then line 12. startTransition isn't a timer. It sets a mark, calls your function, and removes the mark; every update made while the mark is set, like line 10's, is a transition. Second, so an update made later, after the function has returned, isn't one:
startTransition(() => {
setTimeout(() => {
setTab("reviews"); // runs after startTransition has returned: not a transition
}, 100);
});setTimeout(() => {
startTransition(() => {
setTab("reviews"); // a transition
});
}, 100);In the first block, the function only starts a timer and returns; setTab runs 100 ms later, when the mark is gone. In the second, the timer calls startTransition, and setTab runs inside it. Both are fragments, so they aren't live.
Async functions in a transition: actions
Since React 19, the function you pass to startTransition can be async. React calls a function run in a transition an action. The transition then lasts until the async function finishes, so isPending covers the waiting too. Here a cart line saves its quantity on a pretend server (the api.js tab takes 0.8 seconds):
import { useState, useTransition } from "react";import { saveQuantity } from "./api.js"; export default function CartLine() { const [quantity, setQuantity] = useState(1); const [isPending, startTransition] = useTransition(); function addOne() { startTransition(async () => { const saved = await saveQuantity(quantity + 1); startTransition(() => { setQuantity(saved); }); }); } return ( <> <p>Green tea × {quantity} {isPending && "(saving…)"}</p> <button disabled={isPending} onClick={addOne}>Add one</button> </> );}Line 9 starts the transition with an async function. Line 10 waits for the save. Lines 11 to 13 set the saved quantity in another startTransition: after an await, React can't tell that the code still belongs to the transition (JavaScript has no way yet to carry that mark across an await), so React's documentation asks you to wrap updates after an await again. Line 19 shows (saving…), and line 20 disables the button, for the whole 0.8 seconds. Click Add one: (saving…), the console's Save 2, then Green tea × 2.
Before actions, you kept a pending flag yourself:
const [saving, setSaving] = useState(false);
async function addOne() {
setSaving(true);
const saved = await saveQuantity(quantity + 1);
setQuantity(saved);
setSaving(false);
}Lines 4 and 7 set and clear the flag, and nothing clears it if the save throws. With an action, React keeps isPending for you, and an error thrown in the action goes to the nearest error boundary, the Error boundaries lesson's subject. You've already used actions without calling startTransition: a form's action prop runs its function in a transition, as Form actions showed, and Optimistic UI built on the same rule about wrapping updates after an await. Exercise 5 shows what goes wrong when you forget it.
Keep the old page while the new one loads
Transitions matter most when new content isn't ready. In Suspense, a plain update that made a shown component wait replaced it with a fallback. Here is a small app with two pages. The orders page reads its data with use, and the data.js tab takes 1.2 seconds to answer, the first time.
import { Suspense, use, useState } from "react";import { fetchOrders } from "./data.js"; function ShopPage() { return <p>Teas, cakes, and scones.</p>;} function OrdersPage() { const orders = use(fetchOrders()); return ( <ul> {orders.map((order) => <li key={order}>{order}</li>)} </ul> );} export default function App() { const [page, setPage] = useState("shop"); function go(next) { setPage(next); } return ( <> <nav> <button onClick={() => go("shop")}>Shop</button> <button onClick={() => go("orders")}>Your orders</button> </nav> <Suspense fallback={<p>Loading the page…</p>}> {page === "shop" ? <ShopPage /> : <OrdersPage />} </Suspense> </> );}Click Your orders. Line 21 sets the page, OrdersPage suspends on line 9, and the boundary on lines 30 to 32 hides the shop and shows Loading the page… for 1.2 seconds. The page you were reading is gone, and there's nothing to read meanwhile. Now the same app, going to a page in a transition:
import { Suspense, use, useState, useTransition } from "react";import { fetchOrders } from "./data.js"; function ShopPage() { return <p>Teas, cakes, and scones.</p>;} function OrdersPage() { const orders = use(fetchOrders()); return ( <ul> {orders.map((order) => <li key={order}>{order}</li>)} </ul> );} export default function App() { const [page, setPage] = useState("shop"); const [isPending, startTransition] = useTransition(); function go(next) { startTransition(() => { setPage(next); }); } return ( <> <nav> <button onClick={() => go("shop")}>Shop</button> <button onClick={() => go("orders")}>Your orders</button> {isPending && " Loading…"} </nav> <Suspense fallback={<p>Loading the page…</p>}> {page === "shop" ? <ShopPage /> : <OrdersPage />} </Suspense> </> );}Lines 21 to 25 set the page inside startTransition. Click Your orders: the shop stays, the menu says Loading… (line 32), and after 1.2 seconds the orders replace the shop in one step. In a transition, React doesn't hide content that's already on screen; it waits until the new page can render. (Press Shop and Your orders again: the orders come from the cache in data.js now, so there's no wait.)
isPending in the menu, until the orders page can show.That's why routers in frameworks start every navigation as a transition. A transition only keeps what's already showing, though. A Suspense boundary that appears for the first time, with nothing old to keep, still shows its fallback.
What a transition doesn't do
A transition changes the order in which React renders. It doesn't change anything outside React. Here a search asks a pretend server on every key, inside an action:
import { useState, useTransition } from "react";import { searchTeas } from "./api.js"; export default function Search() { const [text, setText] = useState(""); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); function handleChange(event) { const next = event.target.value; setText(next); startTransition(async () => { const found = await searchTeas(next); startTransition(() => { setResults(found); }); }); } return ( <> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Searching…"} <ul> {results.map((name) => <li key={name}>{name}</li>)} </ul> </> );}Lines 12 to 17 run on every key, and line 13 asks the server each time. Type green: the console says Ask the server for "g", then "gr", and so on, five requests. A transition doesn't wait for you to stop typing, and React can't take back a request your code already sent. Waiting for a pause is a debounce:
import { useEffect, useState } from "react";import { searchTeas } from "./api.js"; export default function Search() { const [text, setText] = useState(""); const [results, setResults] = useState([]); useEffect(() => { let ignore = false; const timer = setTimeout(async () => { const found = await searchTeas(text); if (!ignore) setResults(found); }, 300); return () => { ignore = true; clearTimeout(timer); }; }, [text]); return ( <> <input value={text} onChange={(event) => setText(event.target.value)} placeholder="Search teas" /> <ul> {results.map((name) => <li key={name}>{name}</li>)} </ul> </> );}The effect on lines 8 to 18 starts a 300 ms timer for each new text, and its cleanup cancels the timer, so only the text you paused on reaches line 11. When the page opens, it asks once for the empty text. Type green quickly: one more request, for "green". The ignore flag on line 12 drops an old answer, as in Fetching data. To avoid asking twice for the same text, a cache helps, like the one in Data fetching libraries.
How to read the table: each row is one question, answered for a transition and for a debounce.
| Question | A transition | A debounce |
|---|---|---|
| When does the work start? | At once, on every key. | Only once you stop typing for a while, like 300 ms. |
| Does every key cost something? | Every key starts a render. Unfinished ones are thrown away. | No. Keys typed before the pause cost nothing. |
| Does it send fewer requests? | No. A request in the change handler still goes out on every key. | Yes. Only the text you paused on is sent. |
| Does the box stay fast? | Yes, while the slow part renders in the background. | Only if the update after the pause is quick to render. |
Two more things a transition doesn't do:
- It doesn't make rendering faster. The list still costs the same work. A transition lets other things go first, and throws away work you no longer need.
- It can't split one slow component. React only stops between units of work, between components. If one component loops for 250 ms by itself, the box waits for it, transition or not. Spread slow work over many components, like the rows here, or make it faster.
A tea catalogue with a search and tabs
Here are both ideas in one page: kind buttons that switch in a transition, and a search box whose results follow in one. The Catalogue.jsx tab draws a slow card per tea.
import { useState, useTransition } from "react";import Catalogue from "./Catalogue.jsx";// The page links the styles.css tab for you. function KindButton({ kind, isActive, action }) { const [isPending, startTransition] = useTransition(); if (isActive) return <b>{kind}</b>; return ( <button disabled={isPending} onClick={() => startTransition(() => action())}> {isPending ? `${kind}…` : kind} </button> );} function SearchBox({ action }) { const [text, setText] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => action(event.target.value)); } return ( <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> );} export default function Shop() { const [kind, setKind] = useState("All"); const [query, setQuery] = useState(""); return ( <> <nav> {["All", "Green", "Black", "Mint"].map((name) => ( <KindButton key={name} kind={name} isActive={kind === name} action={() => setKind(name)} /> ))} </nav> <SearchBox action={setQuery} /> <Catalogue kind={kind} query={query} /> </> );}KindButton on lines 5 to 13 owns its useTransition, so its urgent render is just the button: line 9 disables it, and line 10 adds … while its kind loads. SearchBox on lines 15 to 28 owns the box's text, and line 20 hands the query to Shop in a transition. Shop on lines 30 to 44 owns kind and query, and only transitions change them, so the slow Catalogue never renders in an urgent update. Click Green, type 2, and click All, quickly: every click and key answers at once, and the cards follow.
- The text in the search box, as you type
- The tick in a checkbox you just clicked
- A slider's thumb as you drag it
- Opening the account menu you clicked
- The list of results for the search text
- Switching to a tab that renders 500 reviews
- Going to another page of the app
- Sorting a big table after you pick a column
Sort each update: must the page show it right away, or can it render in the background?
Common mix-ups
- "A transition makes rendering faster." No. The same work happens. Urgent updates go first, and work that became useless is thrown away.
- "startTransition runs my function later, like setTimeout." No. It runs it at once. Only the updates made while it runs are marked.
- "Any transition keeps typing fast." No. If the urgent render also renders the slow part, as when the box and the list share one component, typing still lags. Keep the urgent render small.
- "I can put the box's own value in a transition." No. React keeps showing the old text until the transition is done, and keys get lost. The box's value stays urgent.
- "A transition is a debounce." No. It starts at once on every key. Requests in your handler still go out each time.
- "Updates after an await are still part of the transition." No. Wrap them in
startTransitionagain, or a page that suspends shows its fallback. - "A transition runs on another thread." No. It runs on the main thread, in pieces. One component that loops for 250 ms blocks everything, transition or not.
Practice exercises
Each exercise has a live result. Click and type in it, change the code with Edit & run, and open the console to see the renders and the requests.
You click the button. What does the console print, in order? Answer first, then press Edit & run and click it.
import { startTransition, useState } from "react";
export default function App() {
const [tab, setTab] = useState("details");
function showReviews() {
console.log(1);
startTransition(() => {
console.log(2);
setTab("reviews");
});
console.log(3);
}
return <button onClick={showReviews}>Tab: {tab}</button>;
}import { startTransition, useState } from "react"; export default function App() { const [tab, setTab] = useState("details"); function showReviews() { console.log(1); startTransition(() => { console.log(2); setTab("reviews"); }); console.log(3); } return <button onClick={showReviews}>Tab: {tab}</button>;}startTransition calls the function right away, so line 9 runs between line 7 and line 12: 1, 2, 3. Only setTab on line 10 is affected: it's marked as a transition.
The box is fast, but nothing tells the reader that the marks are behind. Show a hint while the transition is pending. What shows next to the box while the list catches up?
import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. function SearchBox({ onSearch }) { const [text, setText] = useState(""); const [, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { onSearch(event.target.value); }); } return ( <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> </p> );} export default function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox onSearch={setQuery} /> <ProductList query={query} /> </> );}import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. function SearchBox({ onSearch }) { const [text, setText] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { onSearch(event.target.value); }); } return ( <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> );} export default function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox onSearch={setQuery} /> <ProductList query={query} /> </> );}Line 7 names isPending, and line 19 shows Updating… while it's true: from the urgent render after your key until the transition's list is on screen.
This search has a transition, and typing still lags. Fix it without removing the transition. After your fix, which component's urgent render shows your key?
import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. export default function App() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { setQuery(event.target.value); }); } return ( <> <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> <ProductList query={query} /> </> );}import { useState, useTransition } from "react";import ProductList from "./ProductList.jsx";// The page links the styles.css tab for you. function SearchBox({ onSearch }) { const [text, setText] = useState(""); const [isPending, startTransition] = useTransition(); function handleChange(event) { setText(event.target.value); startTransition(() => { onSearch(event.target.value); }); } return ( <p> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Updating…"} </p> );} export default function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox onSearch={setQuery} /> <ProductList query={query} /> </> );}Now text and isPending belong to SearchBox, so the urgent render only renders SearchBox. App's query changes only in the transition, and only then does the slow list render.
Click Reviews: the page freezes until 120 reviews have rendered. Switch tabs in a transition, and mark the pending tab. While the reviews render, what does the Reviews tab say?
import { useState } from "react";import Reviews from "./Reviews.jsx";// The page links the styles.css tab for you. function TabButton({ children, isActive, action }) { if (isActive) return <b>{children}</b>; return <button onClick={action}>{children}</button>;} export default function ProductTabs() { const [tab, setTab] = useState("details"); return ( <> <nav> <TabButton isActive={tab === "details"} action={() => setTab("details")}>Details</TabButton> <TabButton isActive={tab === "reviews"} action={() => setTab("reviews")}>Reviews</TabButton> <TabButton isActive={tab === "shipping"} action={() => setTab("shipping")}>Shipping</TabButton> </nav> {tab === "details" && <p>Green tea, from the hills of Darjeeling.</p>} {tab === "reviews" && <Reviews />} {tab === "shipping" && <p>Ships in 2 days.</p>} </> );}import { useState, useTransition } from "react";import Reviews from "./Reviews.jsx";// The page links the styles.css tab for you. function TabButton({ children, isActive, action }) { const [isPending, startTransition] = useTransition(); if (isActive) return <b>{children}</b>; if (isPending) return <b className="pending">{children}…</b>; return ( <button onClick={() => { startTransition(() => { action(); }); }} > {children} </button> );} export default function ProductTabs() { const [tab, setTab] = useState("details"); return ( <> <nav> <TabButton isActive={tab === "details"} action={() => setTab("details")}>Details</TabButton> <TabButton isActive={tab === "reviews"} action={() => setTab("reviews")}>Reviews</TabButton> <TabButton isActive={tab === "shipping"} action={() => setTab("shipping")}>Shipping</TabButton> </nav> {tab === "details" && <p>Green tea, from the hills of Darjeeling.</p>} {tab === "reviews" && <Reviews />} {tab === "shipping" && <p>Ships in 2 days.</p>} </> );}Each TabButton starts its own transition, so its urgent render only changes the button, which says Reviews… while the reviews render in the background. A click on Shipping meanwhile is handled at once.
Going to a page now saves the visit first. The shop page vanishes into Loading the page…. Keep it on screen. After your fix, what stays on screen while your orders load?
import { Suspense, use, useState, useTransition } from "react";import { fetchOrders, saveVisit } from "./data.js"; function ShopPage() { return <p>Teas, cakes, and scones.</p>;} function OrdersPage() { const orders = use(fetchOrders()); return ( <ul> {orders.map((order) => <li key={order}>{order}</li>)} </ul> );} export default function App() { const [page, setPage] = useState("shop"); const [isPending, startTransition] = useTransition(); function go(next) { startTransition(async () => { await saveVisit(); setPage(next); }); } return ( <> <nav> <button onClick={() => go("shop")}>Shop</button> <button onClick={() => go("orders")}>Your orders</button> {isPending && " Loading…"} </nav> <Suspense fallback={<p>Loading the page…</p>}> {page === "shop" ? <ShopPage /> : <OrdersPage />} </Suspense> </> );}import { Suspense, use, useState, useTransition } from "react";import { fetchOrders, saveVisit } from "./data.js"; function ShopPage() { return <p>Teas, cakes, and scones.</p>;} function OrdersPage() { const orders = use(fetchOrders()); return ( <ul> {orders.map((order) => <li key={order}>{order}</li>)} </ul> );} export default function App() { const [page, setPage] = useState("shop"); const [isPending, startTransition] = useTransition(); function go(next) { startTransition(async () => { await saveVisit(); startTransition(() => { setPage(next); }); }); } return ( <> <nav> <button onClick={() => go("shop")}>Shop</button> <button onClick={() => go("orders")}>Your orders</button> {isPending && " Loading…"} </nav> <Suspense fallback={<p>Loading the page…</p>}> {page === "shop" ? <ShopPage /> : <OrdersPage />} </Suspense> </> );}After await saveVisit(), setPage was a plain update, so when OrdersPage suspended, React showed the fallback. Inside a second startTransition, it's a transition again, and the shop stays until the orders are ready.
The search asks the server on every key. Make it ask only once you stop typing for 300 ms. With your fix, how many requests does typing green quickly send?
import { useState, useTransition } from "react";import { searchTeas } from "./api.js"; export default function Search() { const [text, setText] = useState(""); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); function handleChange(event) { const next = event.target.value; setText(next); startTransition(async () => { const found = await searchTeas(next); startTransition(() => { setResults(found); }); }); } return ( <> <input value={text} onChange={handleChange} placeholder="Search teas" /> {isPending && " Searching…"} <ul> {results.map((name) => <li key={name}>{name}</li>)} </ul> </> );}import { useEffect, useState } from "react";import { searchTeas } from "./api.js"; export default function Search() { const [text, setText] = useState(""); const [results, setResults] = useState([]); useEffect(() => { let ignore = false; const timer = setTimeout(async () => { const found = await searchTeas(text); if (!ignore) setResults(found); }, 300); return () => { ignore = true; clearTimeout(timer); }; }, [text]); return ( <> <input value={text} onChange={(event) => setText(event.target.value)} placeholder="Search teas" /> <ul> {results.map((name) => <li key={name}>{name}</li>)} </ul> </> );}Each key changes text, which cleans up the last effect's timer and starts a new one. Only the text you paused on lives 300 ms, so typing green quickly asks the server once.
Check your understanding
For each question, ask which update is urgent, which is a transition, and what is already on the screen.
Question 1 of 8Which of these updates should stay urgent?
Choose an answer to see the explanation.
Question 2 of 8What does
isPendingfromuseTransitiontell you?Choose an answer to see the explanation.
Question 3 of 8Is
setTab("reviews")in this code a transition?Read the code, then predictJavaScriptstartTransition(() => { setTimeout(() => { setTab("reviews"); }, 100); });Choose an answer to see the explanation.
Question 4 of 8You type a second key while the list for the first key is still rendering in a transition. What does React do?
Choose an answer to see the explanation.
Question 5 of 8This search uses a transition, and typing still lags. Why?
Read the code, then predictJSXfunction App() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); const [isPending, startTransition] = useTransition(); // handleChange: setText at once, setQuery in startTransition return ( <> <input value={text} onChange={handleChange} /> <SlowList query={query} /> </> ); }Choose an answer to see the explanation.
Question 6 of 8An action,
startTransition(async () => { await save(); … }), is running. When doesisPendinggo back tofalse?Choose an answer to see the explanation.
Question 7 of 8The orders page suspends while its data loads. You go to it inside a transition. What does the reader see meanwhile?
Choose an answer to see the explanation.
Question 8 of 8A search sends a request from its change handler on every key. Does putting that code in
startTransitionsend fewer requests?Choose an answer to see the explanation.
Key takeaways
- Urgent updates render to the end, without stopping. A slow one freezes typing and clicking.
- A transition renders in the background, in pieces. Urgent updates go first, and unfinished transition work is thrown away.
useTransitiongivesisPendingandstartTransition. The standalonestartTransitionfromreactworks anywhere, withoutisPending.- Keep the urgent render small: the box's own value stays urgent, in a component that doesn't render the slow part.
- Since React 19, the function can be async: an action. Wrap updates after an
awaitinstartTransitionagain. - In a transition, React keeps content that's already on screen instead of showing a Suspense fallback.
- A transition isn't a debounce, doesn't touch the network, and can't split one slow component.
Remember the one-liner.
Show what the hand did at once, and let React render what follows in the background.
Coming next: Deferred values. With useDeferredValue, one piece of state can lag behind on purpose, so a slow list follows your typing in the background.