reducerAction: функция, которая вызывается, когда запускается действие. При вызове она получает предыдущее состояние (сначала переданный initialState, затем предыдущее возвращённое значение) первым аргументом, а затем actionPayload, переданный в dispatchAction.
initialState: значение, которое состояние должно иметь в начале. React игнорирует этот аргумент после первого вызова dispatchAction.
необязательноpermalink: строка с уникальным URL страницы, которую меняет эта форма.
Если reducerAction — серверная функция и форма отправлена до загрузки пакета JavaScript, браузер перейдёт на указанный permalink, а не на URL текущей страницы.
useActionState возвращает массив ровно из трёх значений:
Текущее состояние. При первом рендере оно совпадает с переданным initialState. После вызова dispatchAction оно совпадает со значением, которое вернула reducerAction.
Функция dispatchAction, которую вызывают внутри действий.
Флаг isPending, который показывает, ожидает ли какое-либо действие, отправленное этим хуком.
useActionState — хук, поэтому его можно вызывать только на верхнем уровне компонента или собственного хука. Его нельзя вызывать в циклах и условиях. Если это нужно, вынесите состояние в новый компонент.
React ставит несколько вызовов dispatchAction в очередь и выполняет их по порядку. Каждый вызов reducerAction получает результат предыдущего вызова.
У функции dispatchAction стабильная идентичность, поэтому её часто не указывают в зависимостях эффекта, но если указать, эффект из-за этого не запустится. Если линтер позволяет опустить зависимость без ошибок, так и можно сделать. Подробнее об удалении зависимостей эффекта.
При использовании permalink на странице назначения должен рендериться тот же компонент формы (с той же reducerAction и тем же permalink), чтобы React знал, как передать состояние. Когда страница становится интерактивной, этот параметр уже ни на что не влияет.
При использовании серверных функций initialState должен быть сериализуемым (обычные объекты, массивы, строки, числа).
Если dispatchAction выбрасывает ошибку, React отменяет все действия в очереди и показывает ближайшую границу ошибки.
Если одновременно идёт несколько действий, React объединяет их. Это ограничение, которое могут снять в будущем релизе.
Каждый раз, когда вы вызываете dispatchAction, React вызывает reducerAction с actionPayload. Редюсер выполняет побочные эффекты, например отправляет данные, и возвращает новое состояние. Если dispatchAction вызван несколько раз, React ставит вызовы в очередь и выполняет их по порядку, поэтому результат предыдущего вызова передаётся как previousState в текущий.
previousState: последнее состояние. Сначала оно равно initialState. После первого вызова dispatchAction оно равно последнему возвращённому состоянию.
необязательноactionPayload: аргумент, переданный в dispatchAction. Тип может быть любым. По соглашениям useReducer это обычно объект со свойством type, которое его определяет, и, необязательно, другими свойствами с дополнительными данными.
reducerAction может быть синхронной или асинхронной. Она может выполнять синхронные действия, например показать уведомление, или асинхронные, например отправить обновления на сервер.
reducerAction не вызывается дважды в <StrictMode>, потому что reducerAction рассчитана на побочные эффекты.
Тип возврата reducerAction должен совпадать с типом initialState. Если TypeScript видит несовпадение, тип состояния, возможно, нужно указать явно.
Если выставлять состояние после await внутри reducerAction, сейчас обновление состояния нужно обернуть в дополнительный startTransition. Подробнее в документации startTransition.
При использовании серверных функций actionPayload должен быть сериализуемым (обычные объекты, массивы, строки, числа).
Почему она называется reducerAction?
Функцию, переданную в useActionState, называют reducer action, потому что:
Она сводит предыдущее состояние к новому, как useReducer.
Это действие, потому что её вызывают внутри перехода, и она может выполнять побочные эффекты.
По смыслу useActionState похож на useReducer, но в редюсере можно делать побочные эффекты.
Каждый клик по «Add Ticket» ставит вызов addToCartAction в очередь. React показывает состояние ожидания, пока не добавятся все билеты, а затем перерендеривает компонент с итоговым состоянием.
Как устроена очередь useActionState
Попробуйте нажать «Add Ticket» несколько раз. Каждый клик ставит в очередь новый addToCartAction. Из-за искусственной задержки в 1 секунду 4 клика займут около 4 секунд.
Так задумано в useActionState.
Нужно дождаться предыдущего результата addToCartAction, чтобы передать prevCount в следующий вызов. Значит, React ждёт окончания предыдущего действия, прежде чем вызвать следующее.
Чтобы обработать несколько типов, можно передать аргумент в dispatchAction.
По соглашению это пишут как switch. Для каждой ветки считается и возвращается следующее состояние. Форма аргумента может быть любой, но обычно передают объекты со свойством type, которое определяет действие.
Когда вы увеличиваете или уменьшаете количество, отправляется "ADD" или "REMOVE". В reducerAction для обновления количества вызываются разные API.
В этом примере состояние ожидания действий заменяет и количество, и сумму. Если нужна немедленная обратная связь, например сразу обновить количество, используйте useOptimistic.
Чем useActionState отличается от useReducer?
Этот пример похож на useReducer, но задачи у них разные:
useReducer ведёт состояние интерфейса. Редюсер должен быть чистым.
useActionState ведёт состояние действий. Редюсер может выполнять побочные эффекты.
useActionState можно считать useReducer для побочных эффектов действий пользователя. Поскольку следующее действие считается по предыдущему, вызовы приходится выполнять по порядку. Если действия должны идти параллельно, используйте useState и useTransition напрямую.
setOptimisticCount сразу обновляет количество, а dispatchAction() ставит updateCartAction в очередь. Индикатор ожидания появляется и у количества, и у суммы, чтобы пользователь видел, что обновление ещё применяется.
У <QuantityStepper> уже есть поддержка переходов, состояния ожидания и оптимистичного обновления количества. Нужно только сказать действию, что менять, а как менять, компонент берёт на себя.
classAbortErrorextendsError{name='AbortError';constructor(message='The operation was aborted'){super(message);}}functionsleep(ms,signal){if(!signal)returnnewPromise((resolve)=>setTimeout(resolve,ms));if(signal.aborted)returnPromise.reject(newAbortError());returnnewPromise((resolve,reject)=>{constid=setTimeout(()=>{signal.removeEventListener('abort',onAbort);resolve();},ms);constonAbort=()=>{clearTimeout(id);reject(newAbortError());};signal.addEventListener('abort',onAbort,{once:true});});}exportasyncfunctionaddToCart(count,opts){awaitsleep(1000,opts?.signal);returncount+1;}exportasyncfunctionremoveFromCart(count,opts){awaitsleep(1000,opts?.signal);returnMath.max(0,count-1);}
Попробуйте несколько раз нажать увеличение или уменьшение: сумма обновится в пределах 1 секунды, сколько бы раз вы ни кликнули. Так происходит, потому что AbortController «завершает» предыдущее действие, и следующее может продолжиться.
Подводный камень
Отменять действие не всегда безопасно.
Например, если действие делает мутацию (запись в базу), отмена сетевого запроса не откатывает изменение на сервере. Поэтому useActionState по умолчанию не отменяет действия. Это безопасно только если побочный эффект можно спокойно проигнорировать или повторить.
Функцию dispatchAction можно передать в проп action у <form>.
В этом случае React сам оборачивает отправку в переход, и startTransition вызывать не нужно. reducerAction получает предыдущее состояние и отправленный FormData:
В этом примере клик по стрелкам степпера отправляет форму, и useActionState вызывает updateCartAction с данными формы. Пример использует useOptimistic, чтобы сразу показать новое количество, пока сервер подтверждает обновление.
Вместе с серверной функциейuseActionState позволяет показать ответ сервера ещё до завершения гидратации (когда React подключается к HTML, отрендеренному на сервере). Необязательный параметр permalink даёт прогрессивное улучшение (форма работает до загрузки JavaScript) на страницах с динамическим содержимым. Обычно это делает за вас фреймворк.
Подробнее об использовании действий с формами — в документации <form>.
С useActionState ошибки обрабатывают двумя способами.
Известные ошибки, например ошибку проверки «quantity not available» с бэкенда, можно вернуть как часть состояния reducerAction и показать в интерфейсе.
Неизвестные ошибки, например undefined is not a function, можно бросить. React отменит все действия в очереди и покажет ближайшую границу ошибки, повторно бросив ошибку из хука useActionState.
import{useActionState,startTransition}from'react';import{ErrorBoundary}from'react-error-boundary';import{addToCart}from'./api';importTotalfrom'./Total';functionCheckout(){const[state,dispatchAction,isPending]=useActionState(async(prevState,quantity)=>{constresult=awaitaddToCart(prevState.count,quantity);if(result.error){// Return the error from the API as statereturn{...prevState,error:`Could not add quanitiy ${quantity}: ${result.error}`};}if(!isPending){// Clear the error state for the first dispatch.return{count:result.count,error:null};}// Return the new count, and any errors that happened.return{count:result.count,error:prevState.error};},{count:0,error:null,});functionhandleAdd(quantity){startTransition(()=>{dispatchAction(quantity);});}return(<divclassName="checkout"><h2>Checkout</h2><divclassName="row"><span>ErasTourTickets</span><span>{isPending&&'🌀 '}Qty:{state.count}</span></div><divclassName="buttons"><buttononClick={()=>handleAdd(1)}>Add1</button><buttononClick={()=>handleAdd(10)}>Add10</button><buttononClick={()=>handleAdd(NaN)}>AddNaN</button></div>{state.error&&<divclassName="error">{state.error}</div>}<hr/><Totalquantity={state.count}isPending={isPending}/></div>);}exportdefaultfunctionApp(){return(<ErrorBoundaryfallbackRender={({resetErrorBoundary})=>(<divclassName="checkout"><h2>Somethingwentwrong</h2><p>Theactioncouldnotbecompleted.</p><buttononClick={resetErrorBoundary}>Tryagain</button></div>)}><Checkout/></ErrorBoundary>);}
exportasyncfunctionaddToCart(count,quantity){awaitnewPromise((resolve)=>setTimeout(resolve,1000));if(quantity>5){return{error:'Quantity not available'};}elseif(isNaN(quantity)){thrownewError('Quantity must be a number');}return{count:count+quantity};}
В этом примере «Add 10» имитирует API, который возвращает ошибку проверки: updateCartAction кладёт её в состояние и показывает рядом. «Add NaN» даёт недопустимое количество, поэтому updateCartAction бросает ошибку, она проходит через useActionState к ErrorBoundary и показывает интерфейс сброса.
Использование информации, возвращаемой действием формы¶
Вызовите useActionState на верхнем уровне вашего компонента, чтобы получить доступ к возвращаемому значению действия из последнего раза, когда форма была отправлена.
useActionState возвращает массив, содержащий ровно два элемента:
Текущее состояние формы, которое первоначально устанавливается в указанное вами начальное состояние, а после отправки формы устанавливается в возвращаемое значение указанного вами действия.
Новое действие, которое вы передаете в <form> в качестве его свойства action.
Когда форма будет отправлена, будет вызвана указанная вами функция действия. Ее возвращаемое значение станет новым текущим состоянием формы.
Предоставленное вами действие также получит новый первый аргумент, а именно текущее состояние формы. При первой отправке формы это будет начальное состояние, которое вы указали, а при последующих отправках - возвращаемое значение, полученное при последнем вызове действия. Остальные аргументы такие же, как если бы useActionState не использовался
import{useState}from'react';import{useActionState}from'react';import{addToCart}from'./actions.js';functionAddToCartForm({itemID,itemTitle}){const[message,formAction]=useActionState(addToCart,null);return(<formaction={formAction}><h2>{itemTitle}</h2><inputtype="hidden"name="itemID"value={itemID}/><buttontype="submit">AddtoCart</button>{message}</form>);}exportdefaultfunctionApp(){return(<><AddToCartFormitemID="1"itemTitle="JavaScript: The Definitive Guide"/><AddToCartFormitemID="2"itemTitle="JavaScript: The Good Parts"/></>);}
1 2 3 4 5 6 7 8 910
'use server';exportasyncfunctionaddToCart(prevState,queryData){constitemID=queryData.get('itemID');if(itemID==='1'){return'Added to cart';}else{return"Couldn't add to cart: the item is sold out.";}}
2. Отображение структурированной информации после отправки формы
Возвращаемое значение действия сервера может быть любым сериализуемым значением. Например, это может быть объект, содержащий логическое значение, указывающее на успешность выполнения действия, сообщение об ошибке или обновленную информацию.
import{useState}from'react';import{useActionState}from'react';import{addToCart}from'./actions.js';functionAddToCartForm({itemID,itemTitle}){const[formState,formAction]=useActionState(addToCart,{});return(<formaction={formAction}><h2>{itemTitle}</h2><inputtype="hidden"name="itemID"value={itemID}/><buttontype="submit">AddtoCart</button>{formState?.success&&(<divclassName="toast">Addedtocart!Yourcartnowhas{' '}{formState.cartSize}items.</div>)}{formState?.success===false&&(<divclassName="error">Failedtoaddtocart:{' '}{formState.message}</div>)}</form>);}exportdefaultfunctionApp(){return(<><AddToCartFormitemID="1"itemTitle="JavaScript: The Definitive Guide"/><AddToCartFormitemID="2"itemTitle="JavaScript: The Good Parts"/></>);}
1 2 3 4 5 6 7 8 910111213141516
'use server';exportasyncfunctionaddToCart(prevState,queryData){constitemID=queryData.get('itemID');if(itemID==='1'){return{success:true,cartSize:12,};}else{return{success:false,message:'The item is sold out.',};}}
При использовании useActionState функция reducerAction получает дополнительный первый аргумент: предыдущее или начальное состояние. Данные отправленной формы поэтому оказываются вторым аргументом, а не первым.
123456789
// Without useActionStatefunctionaction(formData){constname=formData.get('name');}// With useActionStatefunctionaction(prevState,formData){constname=formData.get('name');}
Если вызвать dispatchAction несколько раз и часть вызовов не выполняется, возможно, более ранний dispatchAction бросил ошибку.
Когда reducerAction бросает ошибку, React пропускает все последующие вызовы dispatchAction в очереди.
Чтобы это обработать, ловите ошибки внутри reducerAction и возвращайте состояние с ошибкой, а не бросайте её:
123456789
asyncfunctionmyReducerAction(prevState,data){try{constresult=awaitsubmitData(data);return{success:true,data:result};}catch(error){// ✅ Return error state instead of throwingreturn{success:false,error:error.message};}}
У useActionState нет встроенной функции сброса. Чтобы сбросить состояние, научите reducerAction обрабатывать сигнал сброса:
1 2 3 4 5 6 7 8 91011121314151617181920212223
constinitialState={name:'',error:null};asyncfunctionformAction(prevState,payload){// Handle resetif(payload===null){returninitialState;}// Normal action logicconstresult=awaitsubmitData(payload);returnresult;}functionMyComponent(){const[state,dispatchAction,isPending]=useActionState(formAction,initialState);functionhandleReset(){startTransition(()=>{dispatchAction(null);// Pass null to trigger reset});}// ...}
Либо добавьте проп key компоненту с useActionState, чтобы он смонтировался заново со свежим состоянием, либо проп action у <form>, который сбрасывается сам после отправки.
Ошибка: «An async function with useActionState was called outside of a transition.»¶
<ConsoleLogLine level="error">
An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an `action` or `formAction` prop.
</ConsoleLogLine>
Ошибка возникает, потому что dispatchAction должен выполняться внутри перехода:
1 2 3 4 5 6 7 8 910
functionMyComponent(){const[state,dispatchAction,isPending]=useActionState(myAsyncAction,null);functionhandleClick(){// ❌ Wrong: calling dispatchAction outside a TransitiondispatchAction();}// ...}
import{useActionState,startTransition}from'react';functionMyComponent(){const[state,dispatchAction,isPending]=useActionState(myAsyncAction,null);functionhandleClick(){// ✅ Correct: wrap in startTransitionstartTransition(()=>{dispatchAction();});}// ...}
Либо передайте dispatchAction в проп-действие: его вызов уже будет в переходе:
123456
functionMyComponent(){const[state,dispatchAction,isPending]=useActionState(myAsyncAction,null);// ✅ Correct: action prop wraps in a Transition for youreturn<Buttonaction={dispatchAction}>...</Button>;}
Ошибка: «Cannot update action state while rendering»¶
Нельзя вызывать dispatchAction во время рендера:
Cannot update action state while rendering.
Так получается бесконечный цикл: вызов dispatchAction планирует обновление состояния, оно вызывает повторный рендер, а тот снова вызывает dispatchAction.
12345678
functionMyComponent(){const[state,dispatchAction,isPending]=useActionState(myAction,null);// ❌ Wrong: calling dispatchAction during renderdispatchAction();// ...}
Чтобы исправить, вызывайте dispatchAction только в ответ на действия пользователя (отправка формы или клик по кнопке).
Мое действие больше не может читать данные отправленной формы¶
Когда вы оборачиваете действие с помощью useActionState, оно получает дополнительный аргумент в качестве первого аргумента. Таким образом, отправленные данные формы являются его вторым аргументом, а не первым, как это было бы обычно. Новый первый аргумент, который добавляется, - это текущее состояние формы.