Функции, вызываемые в startTransition, называются действиями
Функция, переданная в startTransition, называется «действием». По соглашению любой колбэк, который вызывают внутри startTransition (например, колбэк в пропсах), стоит называть action или добавлять суффикс Action:
scope: Функция, которая обновляет некоторое состояние, вызывая одну или несколько функций set. React немедленно вызывает scope без параметров и помечает все обновления состояния, запланированные синхронно во время вызова функции scope, как переходы. Они будут неблокирующими и не будут отображать нежелательные индикаторы загрузки.
useTransition - это хук, поэтому его можно вызывать только внутри компонентов или пользовательских хуков. Если вам нужно запустить переход в другом месте (например, из библиотеки данных), вызовите вместо этого отдельный startTransition.
Вы можете обернуть обновление в переход, только если у вас есть доступ к функции set этого состояния. Если вы хотите запустить переход в ответ на какой-то пропс или пользовательское значение Hook, попробуйте вместо этого использовать useDeferredValue.
Функция, которую вы передаете в startTransition, должна быть синхронной. React немедленно выполняет эту функцию, помечая все обновления состояния, которые происходят во время ее выполнения, как переходы. Если вы попытаетесь выполнить дополнительные обновления состояния позже (например, во время таймаута), они не будут помечены как переходы.
Обновление состояния, помеченное как переход, будет прерываться другими обновлениями состояния. Например, если вы обновите компонент графика внутри перехода, а затем начнете вводить текст в поле ввода, когда график находится в середине повторного рендеринга, React перезапустит работу по рендерингу компонента графика после обработки обновления ввода.
Обновления переходов нельзя использовать для управления текстовыми вводами.
При наличии нескольких текущих переходов React в настоящее время собирает их вместе. Это ограничение, которое, вероятно, будет устранено в будущем выпуске.
Функция, переданная в startTransition, называется «действием» (Action). Внутри действия можно обновлять состояние и (необязательно) выполнять побочные эффекты, и эта работа пойдёт в фоне, не блокируя действия пользователя на странице. В переход может входить несколько действий, и пока переход идёт, интерфейс остаётся отзывчивым. Например, если пользователь нажал вкладку, а потом передумал и нажал другую, второй клик обработается сразу, не дожидаясь окончания первого обновления.
Чтобы показать пользователю, что переход ещё идёт, состояние isPending становится true при первом вызове startTransition и остаётся true, пока не завершатся все действия и пользователю не покажут итоговое состояние. Переходы дожидаются побочных эффектов в действиях, чтобы не показывать нежелательные индикаторы загрузки, а немедленную обратную связь во время перехода можно дать через useOptimistic.
В этом примере функция updateQuantity имитирует запрос к серверу, чтобы обновить количество товара в корзине. Функция искусственно замедлена, чтобы запрос занимал не меньше секунды.
Быстро измените количество несколько раз. Обратите внимание: состояние ожидания у «Total» видно, пока идут любые запросы, а «Total» обновляется только после последнего запроса. Поскольку обновление внутри действия, «quantity» можно продолжать менять, пока запрос ещё выполняется.
1 2 3 4 5 6 7 8 9101112
{"dependencies":{"react":"beta","react-dom":"beta"},"scripts":{"start":"react-scripts start","build":"react-scripts build","test":"react-scripts test --env=jsdom","eject":"react-scripts eject"}}
import{useState,useTransition}from"react";import{updateQuantity}from"./api";importItemfrom"./Item";importTotalfrom"./Total";exportdefaultfunctionApp({}){const[quantity,setQuantity]=useState(1);const[isPending,startTransition]=useTransition();constupdateQuantityAction=asyncnewQuantity=>{// To access the pending state of a transition,// call startTransition again.startTransition(async()=>{constsavedQuantity=awaitupdateQuantity(newQuantity);startTransition(()=>{setQuantity(savedQuantity);});});};return(<div><h1>Checkout</h1><Itemaction={updateQuantityAction}/><hr/><Totalquantity={quantity}isPending={isPending}/></div>);}
1 2 3 4 5 6 7 8 910111213141516171819202122
import{startTransition}from"react";exportdefaultfunctionItem({action}){functionhandleChange(event){// To expose an action prop, await the callback in startTransition.startTransition(async()=>{awaitaction(event.target.value);})}return(<divclassName="item"><span>ErasTourTickets</span><labelhtmlFor="name">Quantity:</label><inputtype="number"onChange={handleChange}defaultValue={1}min={1}/></div>)}
Это базовый пример того, как работают действия, но он не обрабатывает запросы, которые завершаются не по порядку. Если быстро менять количество несколько раз, предыдущие запросы могут закончиться после более поздних, и количество обновится не в том порядке. Это известное ограничение, которое исправят в будущем (см. Устранение неполадок ниже).
Они сами следят за порядком запросов. Если на переходах вы строите свои хуки или библиотеки для асинхронных переходов состояния, контроля больше, но порядок нужно обрабатывать самим.
Решение
В этом примере updateQuantity тоже имитирует запрос к серверу, чтобы обновить количество товара в корзине. Функция искусственно замедлена, чтобы запрос занимал не меньше секунды.
Быстро измените количество несколько раз. Обратите внимание: состояние ожидания у «Total» видно, пока идёт любой запрос, но «Total» обновляется несколько раз — по одному на каждый клик по «quantity»:
1 2 3 4 5 6 7 8 9101112
{"dependencies":{"react":"beta","react-dom":"beta"},"scripts":{"start":"react-scripts start","build":"react-scripts build","test":"react-scripts test --env=jsdom","eject":"react-scripts eject"}}
import{useState}from"react";import{updateQuantity}from"./api";importItemfrom"./Item";importTotalfrom"./Total";exportdefaultfunctionApp({}){const[quantity,setQuantity]=useState(1);const[isPending,setIsPending]=useState(false);constonUpdateQuantity=asyncnewQuantity=>{// Manually set the isPending State.setIsPending(true);constsavedQuantity=awaitupdateQuantity(newQuantity);setIsPending(false);setQuantity(savedQuantity);};return(<div><h1>Checkout</h1><ItemonUpdateQuantity={onUpdateQuantity}/><hr/><Totalquantity={quantity}isPending={isPending}/></div>);}
import{useState,useTransition}from"react";import{updateQuantity}from"./api";importItemfrom"./Item";importTotalfrom"./Total";exportdefaultfunctionApp({}){const[quantity,setQuantity]=useState(1);const[isPending,setIsPending]=useState(false);constonUpdateQuantity=asyncevent=>{constnewQuantity=event.target.value;// Manually set the isPending state.setIsPending(true);constsavedQuantity=awaitupdateQuantity(newQuantity);setIsPending(false);setQuantity(savedQuantity);};return(<div><h1>Checkout</h1><ItemisPending={isPending}onUpdateQuantity={onUpdateQuantity}/><hr/><Totalquantity={quantity}isPending={isPending}/></div>);}
Так приложение кажется медленным: пользователю приходится ждать при каждом изменении количества. Более сложную обработку можно написать вручную, чтобы интерфейс оставался отзывчивым, но действия закрывают этот случай простым встроенным API.
Компонент может отдать наружу проп action, чтобы родитель вызвал действие.
Например, этот TabButton оборачивает логику onClick в проп action:
1 2 3 4 5 6 7 8 91011121314151617
exportdefaultfunctionTabButton({action,children,isActive}){const[isPending,startTransition]=useTransition();if(isActive){return<b>{children}</b>}return(<buttononClick={()=>{startTransition(async()=>{// await the action that's passed in.// This allows it to be either sync or async.awaitaction();});}}>{children}</button>);}
Поскольку родитель обновляет своё состояние внутри action, это обновление помечается как переход. Можно нажать «Posts», а сразу затем «Contact», и это не заблокирует действия пользователя:
import{useTransition}from'react';exportdefaultfunctionTabButton({action,children,isActive}){const[isPending,startTransition]=useTransition();if(isActive){return<b>{children}</b>}if(isPending){return<bclassName="pending">{children}</b>;}return(<buttononClick={async()=>{startTransition(async()=>{// await the action that's passed in.// This allows it to be either sync or async.awaitaction();});}}>{children}</button>);}
import{memo}from'react';constPostsTab=memo(functionPostsTab(){// Log once. The actual slowdown is inside SlowPost.console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');letitems=[];for(leti=0;i<500;i++){items.push(<SlowPostkey={i}index={i}/>);}return(<ulclassName="items">{items}</ul>);});functionSlowPost({index}){letstartTime=performance.now();while(performance.now()-startTime<1){// Do nothing for 1 ms per item to emulate extremely slow code}return(<liclassName="item">Post#{index+1}</li>);}exportdefaultPostsTab;
Булево значение isPending, которое возвращает useTransition, показывает пользователю, что переход ещё идёт. Например, у кнопки вкладки может быть особое визуальное состояние «pending»:
import{memo}from'react';constPostsTab=memo(functionPostsTab(){// Log once. The actual slowdown is inside SlowPost.console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');letitems=[];for(leti=0;i<500;i++){items.push(<SlowPostkey={i}index={i}/>);}return(<ulclassName="items">{items}</ul>);});functionSlowPost({index}){letstartTime=performance.now();while(performance.now()-startTime<1){// Do nothing for 1 ms per item to emulate extremely slow code}return(<liclassName="item">Post#{index+1}</li>);}exportdefaultPostsTab;
Переходы позволяют сохранить отзывчивость обновлений пользовательского интерфейса даже на медленных устройствах.
С помощью перехода пользовательский интерфейс остается отзывчивым в середине повторного рендеринга. Например, если пользователь нажал на вкладку, но затем передумал и нажал на другую вкладку, он может сделать это, не дожидаясь окончания первого повторного рендеринга.
Разница между useTransition и обычным обновлением состояния¶
1. Обновление текущей вкладки в переходе
В этом примере вкладка "Posts" искусственно замедлена, так что на ее отображение уходит не менее секунды.
Нажмите "Posts", а затем сразу же нажмите "Contact". Обратите внимание, что это прерывает медленное отображение "Posts". Вкладка "Контакт" отображается немедленно. Поскольку это обновление состояния отмечено как переход, медленный повторный рендеринг не привел к зависанию пользовательского интерфейса.
import{memo}from'react';constPostsTab=memo(functionPostsTab(){// Log once. The actual slowdown is inside SlowPost.console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');letitems=[];for(leti=0;i<500;i++){items.push(<SlowPostkey={i}index={i}/>);}return<ulclassName="items">{items}</ul>;});functionSlowPost({index}){letstartTime=performance.now();while(performance.now()-startTime<1){// Do nothing for 1 ms per item to emulate extremely slow code}return<liclassName="item">Post#{index+1}</li>;}exportdefaultPostsTab;
В этом примере вкладка "Posts" также искусственно замедляется, так что на ее отображение уходит не менее секунды. В отличие от предыдущего примера, это обновление состояния не является переходом.
Нажмите "Сообщения", а затем сразу же нажмите "Контакт". Обратите внимание, что приложение замирает во время рендеринга замедленной вкладки, а пользовательский интерфейс становится неотзывчивым. Это обновление состояния не является переходом, поэтому медленный повторный рендеринг заморозил пользовательский интерфейс.
import{memo}from'react';constPostsTab=memo(functionPostsTab(){// Log once. The actual slowdown is inside SlowPost.console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');letitems=[];for(leti=0;i<500;i++){items.push(<SlowPostkey={i}index={i}/>);}return<ulclassName="items">{items}</ul>;});functionSlowPost({index}){letstartTime=performance.now();while(performance.now()-startTime<1){// Do nothing for 1 ms per item to emulate extremely slow code}return<liclassName="item">Post#{index+1}</li>;}exportdefaultPostsTab;
Вы можете обновить состояние родительского компонента и из вызова useTransition. Например, этот компонент TabButton обернул свою логику onClick в переход:
Поскольку родительский компонент обновляет свое состояние внутри обработчика события onClick, это обновление состояния помечается как переход. Вот почему, как в предыдущем примере, вы можете щелкнуть на "Posts", а затем сразу же щелкнуть на "Contact". Обновление выбранной вкладки помечается как переход, поэтому оно не блокирует взаимодействие с пользователем.
import{memo}from'react';constPostsTab=memo(functionPostsTab(){// Log once. The actual slowdown is inside SlowPost.console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');letitems=[];for(leti=0;i<500;i++){items.push(<SlowPostkey={i}index={i}/>);}return<ulclassName="items">{items}</ul>;});functionSlowPost({index}){letstartTime=performance.now();while(performance.now()-startTime<1){// Do nothing for 1 ms per item to emulate extremely slow code}return<liclassName="item">Post#{index+1}</li>;}exportdefaultPostsTab;
Отображение ожидающего визуального состояния во время перехода¶
Вы можете использовать булево значение isPending, возвращаемое useTransition, чтобы указать пользователю, что переход находится в процессе. Например, кнопка табуляции может иметь специальное визуальное состояние "в ожидании":
import{memo}from'react';constPostsTab=memo(functionPostsTab(){// Log once. The actual slowdown is inside SlowPost.console.log('[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />');letitems=[];for(leti=0;i<500;i++){items.push(<SlowPostkey={i}index={i}/>);}return<ulclassName="items">{items}</ul>;});functionSlowPost({index}){letstartTime=performance.now();while(performance.now()-startTime<1){// Do nothing for 1 ms per item to emulate extremely slow code}return<liclassName="item">Post#{index+1}</li>;}exportdefaultPostsTab;
В этом примере компонент PostsTab получает некоторые данные, используя источник данных Suspense-enabled. Когда вы нажимаете на вкладку "Posts", компонент PostsTabприостанавливается, вызывая появление ближайшего фалбэка загрузки:
Скрытие всего контейнера вкладки для отображения индикатора загрузки приводит к искажению пользовательского опыта. Если вы добавите useTransition к TabButton, вы можете вместо этого указать отображение состояния ожидания в кнопке вкладки.
Обратите внимание, что при нажатии на кнопку "Posts" весь контейнер вкладки больше не заменяется спиннером:
Переходы будут "ждать" только достаточно долго, чтобы избежать скрытия уже раскрытого содержимого (например, контейнера вкладки). Если вкладка Posts имеет вложенную границу <Suspense>, переход не будет "ждать" ее.
Suspense-enabled ожидается, что маршрутизаторы по умолчанию будут оборачивать обновления навигации в переходы.
Отображение ошибки для пользователей с границей ошибки¶
Если функция, переданная в startTransition, выкидывает ошибку, вы можете отобразить ее пользователю с помощью границы ошибки. Чтобы использовать границу ошибки, оберните компонент, в котором вызывается useTransition, в границу ошибки. После того как функция, переданная в startTransition, ошибется, будет отображена обратная связь для границы ошибки.
import{useTransition}from'react';import{ErrorBoundary}from'react-error-boundary';exportfunctionAddCommentContainer(){return(<ErrorBoundaryfallback={<p>⚠️Somethingwentwrong</p>}><AddCommentButton/></ErrorBoundary>);}functionaddComment(comment){// For demonstration purposes to show Error Boundaryif(comment==null){thrownewError('Example Error: An error thrown to trigger error boundary');}}functionAddCommentButton(){const[pending,startTransition]=useTransition();return(<buttondisabled={pending}onClick={()=>{startTransition(()=>{// Intentionally not passing a comment// so error gets thrownaddComment();});}}>Addcomment</button>);}
Вы не можете использовать переход для переменной состояния, которая управляет входом:
1 2 3 4 5 6 7 8 910
const[text,setText]=useState('');// ...functionhandleChange(e){// ❌ Can't use transitions for controlled input statestartTransition(()=>{setText(e.target.value);});}// ...return<inputvalue={text}onChange={handleChange}/>;
Это связано с тем, что переходы являются неблокирующими, но обновление ввода в ответ на событие изменения должно происходить синхронно. Если вы хотите запустить переход в ответ на ввод текста, у вас есть два варианта:
Вы можете объявить две отдельные переменные состояния: одну для состояния ввода (которая всегда обновляется синхронно) и одну, которую вы будете обновлять в переходе. Это позволит вам управлять вводом, используя синхронное состояние, и передать переменную состояния перехода (которая будет "отставать" от ввода) остальной части вашей логики рендеринга.
В качестве альтернативы вы можете иметь одну переменную состояния и добавить useDeferredValue, которая будет "отставать" от реального значения. Это вызовет неблокирующие повторные рендеринги, чтобы автоматически "догнать" новое значение.
React не рассматривает обновление моего состояния как переход¶
Когда вы оборачиваете обновление состояния в переход, убедитесь, что оно происходит во время вызова startTransition:
1234
startTransition(()=>{// ✅ Setting state *during* startTransition callsetPage('/about');});
Функция, которую вы передаете в startTransition, должна быть синхронной.
Вы не можете пометить обновление как переход таким образом:
123456
startTransition(()=>{// ❌ Setting state *after* startTransition callsetTimeout(()=>{setPage('/about');},1000);});
Вместо этого вы можете сделать следующее:
123456
setTimeout(()=>{startTransition(()=>{// ✅ Setting state *during* startTransition callsetPage('/about');});},1000);
Точно так же вы не можете пометить обновление как переход:
12345
startTransition(async()=>{awaitsomeAsyncFunction();// ❌ Setting state *after* startTransition callsetPage('/about');});
Однако это работает вместо этого:
12345
awaitsomeAsyncFunction();startTransition(()=>{// ✅ Setting state *during* startTransition callsetPage('/about');});
React не считает обновление состояния после await переходом¶
Если внутри функции startTransition есть await, обновления состояния после await не помечаются как переходы. Каждое обновление состояния после await нужно обернуть в вызов startTransition:
12345
startTransition(async()=>{awaitsomeAsyncFunction();// ❌ Not using startTransition after awaitsetPage('/about');});
А вот так это работает:
1234567
startTransition(async()=>{awaitsomeAsyncFunction();// ✅ Using startTransition *after* awaitstartTransition(()=>{setPage('/about');});});
Это ограничение JavaScript: React теряет область асинхронного контекста. В будущем, когда появится AsyncContext, ограничение уберут.
Вы не можете вызвать useTransition вне компонента, потому что это Hook. В этом случае вместо него используйте отдельный метод startTransition. Он работает так же, но не предоставляет индикатор isPending.
Функция, которую я передаю в startTransition, выполняется немедленно¶
Ожидается, что будет выведено 1, 2, 3. Функция, которую вы передаете в startTransition, не задерживается. В отличие от браузера setTimeout, он не запускает обратный вызов позже. React выполняет вашу функцию немедленно, но все обновления состояния, запланированные пока она выполняется, помечаются как переходы. Вы можете представить, что это работает следующим образом:
1 2 3 4 5 6 7 8 91011121314151617
// A simplified version of how React worksletisInsideTransition=false;functionstartTransition(scope){isInsideTransition=true;scope();isInsideTransition=false;}functionsetState(){if(isInsideTransition){// ... schedule a transition state update ...}else{// ... schedule an urgent state update ...}}
Обновления состояния в переходах идут не по порядку¶
Если сделать await внутри startTransition, обновления могут прийти не по порядку.
В этом примере updateQuantity имитирует запрос к серверу, чтобы обновить количество товара в корзине. Функция искусственно возвращает каждый второй запрос позже предыдущего, чтобы смоделировать гонки сетевых запросов.
Попробуйте обновить количество один раз, а затем быстро несколько раз. Можно увидеть неверную сумму:
1 2 3 4 5 6 7 8 9101112
{"dependencies":{"react":"beta","react-dom":"beta"},"scripts":{"start":"react-scripts start","build":"react-scripts build","test":"react-scripts test --env=jsdom","eject":"react-scripts eject"}}
import{useState,useTransition}from"react";import{updateQuantity}from"./api";importItemfrom"./Item";importTotalfrom"./Total";exportdefaultfunctionApp({}){const[quantity,setQuantity]=useState(1);const[isPending,startTransition]=useTransition();// Store the actual quantity in separate state to show the mismatch.const[clientQuantity,setClientQuantity]=useState(1);constupdateQuantityAction=newQuantity=>{setClientQuantity(newQuantity);// Access the pending state of the transition,// by wrapping in startTransition again.startTransition(async()=>{constsavedQuantity=awaitupdateQuantity(newQuantity);startTransition(()=>{setQuantity(savedQuantity);});});};return(<div><h1>Checkout</h1><Itemaction={updateQuantityAction}/><hr/><TotalclientQuantity={clientQuantity}savedQuantity={quantity}isPending={isPending}/></div>);}
1 2 3 4 5 6 7 8 910111213141516171819202122
import{startTransition}from'react';exportdefaultfunctionItem({action}){functionhandleChange(e){// Update the quantity in an Action.startTransition(async()=>{awaitaction(e.target.value);});}return(<divclassName="item"><span>ErasTourTickets</span><labelhtmlFor="name">Quantity:</label><inputtype="number"onChange={handleChange}defaultValue={1}min={1}/></div>)}
letfirstRequest=true;exportasyncfunctionupdateQuantity(newName){returnnewPromise((resolve,reject)=>{if(firstRequest===true){firstRequest=false;setTimeout(()=>{firstRequest=true;resolve(newName);// Simulate every other request being slower},1000);}else{setTimeout(()=>{resolve(newName);},50);}});}
Если кликать несколько раз, предыдущие запросы могут закончиться после более поздних. Сейчас React не знает, какой порядок был задуман. Обновления планируются асинхронно, и за асинхронной границей React теряет контекст порядка.
Так и задумано: действия внутри перехода не гарантируют порядок выполнения. Для обычных случаев React даёт абстракции уровнем выше, например useActionState и действия <form>, которые следят за порядком. В сложных случаях свою очередь и отмену нужно реализовать самим.
Пример того, как useActionState сохраняет порядок выполнения:
1 2 3 4 5 6 7 8 9101112
{"dependencies":{"react":"beta","react-dom":"beta"},"scripts":{"start":"react-scripts start","build":"react-scripts build","test":"react-scripts test --env=jsdom","eject":"react-scripts eject"}}
import{useState,useActionState}from"react";import{updateQuantity}from"./api";importItemfrom"./Item";importTotalfrom"./Total";exportdefaultfunctionApp({}){// Store the actual quantity in separate state to show the mismatch.const[clientQuantity,setClientQuantity]=useState(1);const[quantity,updateQuantityAction,isPending]=useActionState(async(prevState,payload)=>{setClientQuantity(payload);constsavedQuantity=awaitupdateQuantity(payload);returnsavedQuantity;// Return the new quantity to update the state},1// Initial quantity);return(<div><h1>Checkout</h1><Itemaction={updateQuantityAction}/><hr/><TotalclientQuantity={clientQuantity}savedQuantity={quantity}isPending={isPending}/></div>);}
1 2 3 4 5 6 7 8 910111213141516171819202122
import{startTransition}from'react';exportdefaultfunctionItem({action}){functionhandleChange(e){// Update the quantity in an Action.startTransition(()=>{action(e.target.value);});}return(<divclassName="item"><span>ErasTourTickets</span><labelhtmlFor="name">Quantity:</label><inputtype="number"onChange={handleChange}defaultValue={1}min={1}/></div>)}
letfirstRequest=true;exportasyncfunctionupdateQuantity(newName){returnnewPromise((resolve,reject)=>{if(firstRequest===true){firstRequest=false;setTimeout(()=>{firstRequest=true;resolve(newName);// Simulate every other request being slower},1000);}else{setTimeout(()=>{resolve(newName);},50);}});}