@samitouri / QOS-React / commits / 5c65b27587

Add `React.useActionState` (#28491)

## Overview _Depends on https://github.com/facebook/react/pull/28514_ This PR adds a new React hook called `useActionState` to replace and improve the ReactDOM `useFormState` hook. ## Motivation This hook intends to fix some of the confusion and limitations of the `useFormState` hook. The `useFormState` hook is only exported from the `ReactDOM` package and implies that it is used only for the state of `<form>` actions, similar to `useFormStatus` (which is only for `<form>` element status). This leads to understandable confusion about why `useFormState` does not provide a `pending` state value like `useFormStatus` does. The key insight is that the `useFormState` hook does not actually return the state of any particular form at all. Instead, it returns the state of the _action_ passed to the hook, wrapping it and returning a trackable action to add to a form, and returning the last returned value of the action given. In fact, `useFormState` doesn't need to be used in a `<form>` at all. Thus, adding a `pending` value to `useFormState` as-is would thus be confusing because it would only return the pending state of the _action_ given, not the `<form>` the action is passed to. Even if we wanted to tie them together, the returned `action` can be passed to multiple forms, creating confusing and conflicting pending states during multiple form submissions. Additionally, since the action is not related to any particular `<form>`, the hook can be used in any renderer - not only `react-dom`. For example, React Native could use the hook to wrap an action, pass it to a component that will unwrap it, and return the form result state and pending state. It's renderer agnostic. To fix these issues, this PR: - Renames `useFormState` to `useActionState` - Adds a `pending` state to the returned tuple - Moves the hook to the `'react'` package ## Reference The `useFormState` hook allows you to track the pending state and return value of a function (called an "action"). The function passed can be a plain JavaScript client function, or a bound server action to a reference on the server. It accepts an optional `initialState` value used for the initial render, and an optional `permalink` argument for renderer specific pre-hydration handling (such as a URL to support progressive hydration in `react-dom`). Type: ```ts function useActionState<State>( action: (state: Awaited<State>) => State | Promise<State>, initialState: Awaited<State>, permalink?: string, ): [state: Awaited<State>, dispatch: () => void, boolean]; ``` The hook returns a tuple with: - `state`: the last state the action returned - `dispatch`: the method to call to dispatch the wrapped action - `pending`: the pending state of the action and any state updates contained Notably, state updates inside of the action dispatched are wrapped in a transition to keep the page responsive while the action is completing and the UI is updated based on the result. ## Usage The `useActionState` hook can be used similar to `useFormState`: ```js import { useActionState } from "react"; // not react-dom function Form({ formAction }) { const [state, action, isPending] = useActionState(formAction); return ( <form action={action}> <input type="email" name="email" disabled={isPending} /> <button type="submit" disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </form> ); } ``` But it doesn't need to be used with a `<form/>` (neither did `useFormState`, hence the confusion): ```js import { useActionState, useRef } from "react"; function Form({ someAction }) { const ref = useRef(null); const [state, action, isPending] = useActionState(someAction); async function handleSubmit() { // See caveats below await action({ email: ref.current.value }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` ## Benefits One of the benefits of using this hook is the automatic tracking of the return value and pending states of the wrapped function. For example, the above example could be accomplished via: ```js import { useActionState, useRef } from "react"; function Form({ someAction }) { const ref = useRef(null); const [state, setState] = useState(null); const [isPending, setIsPending] = useTransition(); function handleSubmit() { startTransition(async () => { const response = await someAction({ email: ref.current.value }); setState(response); }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` However, this hook adds more benefits when used with render specific elements like react-dom `<form>` elements and Server Action. With `<form>` elements, React will automatically support replay actions on the form if it is submitted before hydration has completed, providing a form of partial progressive enhancement: enhancement for when javascript is enabled but not ready. Additionally, with the `permalink` argument and Server Actions, frameworks can provide full progressive enhancement support, submitting the form to the URL provided along with the FormData from the form. On submission, the Server Action will be called during the MPA navigation, similar to any raw HTML app, server rendered, and the result returned to the client without any JavaScript on the client. ## Caveats There are a few Caveats to this new hook: **Additional state update**: Since we cannot know whether you use the pending state value returned by the hook, the hook will always set the `isPending` state at the beginning of the first chained action, resulting in an additional state update similar to `useTransition`. In the future a type-aware compiler could optimize this for when the pending state is not accessed. **Pending state is for the action, not the handler**: The difference is subtle but important, the pending state begins when the return action is dispatched and will revert back after all actions and transitions have settled. The mechanism for this under the hook is the same as useOptimisitic. Concretely, what this means is that the pending state of `useActionState` will not represent any actions or sync work performed before dispatching the action returned by `useActionState`. Hopefully this is obvious based on the name and shape of the API, but there may be some temporary confusion. As an example, let's take the above example and await another action inside of it: ```js import { useActionState, useRef } from "react"; function Form({ someAction, someOtherAction }) { const ref = useRef(null); const [state, action, isPending] = useActionState(someAction); async function handleSubmit() { await someOtherAction(); // The pending state does not start until this call. await action({ email: ref.current.value }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` Since the pending state is related to the action, and not the handler or form it's attached to, the pending state only changes when the action is dispatched. To solve, there are two options. First (recommended): place the other function call inside of the action passed to `useActionState`: ```js import { useActionState, useRef } from "react"; function Form({ someAction, someOtherAction }) { const ref = useRef(null); const [state, action, isPending] = useActionState(async (data) => { // Pending state is true already. await someOtherAction(); return someAction(data); }); async function handleSubmit() { // The pending state starts at this call. await action({ email: ref.current.value }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` For greater control, you can also wrap both in a transition and use the `isPending` state of the transition: ```js import { useActionState, useTransition, useRef } from "react"; function Form({ someAction, someOtherAction }) { const ref = useRef(null); // isPending is used from the transition wrapping both action calls. const [isPending, startTransition] = useTransition(); // isPending not used from the individual action. const [state, action] = useActionState(someAction); async function handleSubmit() { startTransition(async () => { // The transition pending state has begun. await someOtherAction(); await action({ email: ref.current.value }); }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` A similar technique using `useOptimistic` is preferred over using `useTransition` directly, and is left as an exercise to the reader. ## Thanks Thanks to @ryanflorence @mjackson @wesbos (https://github.com/facebook/react/issues/27980#issuecomment-1960685940) and [Allan Lasser](https://allanlasser.com/posts/2024-01-26-avoid-using-reacts-useformstatus) for their feedback and suggestions on `useFormStatus` hook.

Ricky committed Mar 22, 2024 at 13:03 UTC 5c65b27587c0507d66a84e055de948fc62d471d4
18 files changed +262 -48
packages/react-debug-tools/src/ReactDebugHooks.js
+74
@@ -106,6 +106,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
106 // This type check is for Flow only.
107 Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
108 }
109 + if (typeof Dispatcher.useActionState === 'function') {
110 + // This type check is for Flow only.
111 + Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
112 + }
113 if (typeof Dispatcher.use === 'function') {
114 // This type check is for Flow only.
115 Dispatcher.use(
@@ -613,6 +617,75 @@ function useFormState<S, P>(
617 return [state, (payload: P) => {}, false];
618 }
619
620 +function useActionState<S, P>(
621 + action: (Awaited<S>, P) => S,
622 + initialState: Awaited<S>,
623 + permalink?: string,
624 +): [Awaited<S>, (P) => void, boolean] {
625 + const hook = nextHook(); // FormState
626 + nextHook(); // PendingState
627 + nextHook(); // ActionQueue
628 + const stackError = new Error();
629 + let value;
630 + let debugInfo = null;
631 + let error = null;
632 +
633 + if (hook !== null) {
634 + const actionResult = hook.memoizedState;
635 + if (
636 + typeof actionResult === 'object' &&
637 + actionResult !== null &&
638 + // $FlowFixMe[method-unbinding]
639 + typeof actionResult.then === 'function'
640 + ) {
641 + const thenable: Thenable<Awaited<S>> = (actionResult: any);
642 + switch (thenable.status) {
643 + case 'fulfilled': {
644 + value = thenable.value;
645 + debugInfo =
646 + thenable._debugInfo === undefined ? null : thenable._debugInfo;
647 + break;
648 + }
649 + case 'rejected': {
650 + const rejectedError = thenable.reason;
651 + error = rejectedError;
652 + break;
653 + }
654 + default:
655 + // If this was an uncached Promise we have to abandon this attempt
656 + // but we can still emit anything up until this point.
657 + error = SuspenseException;
658 + debugInfo =
659 + thenable._debugInfo === undefined ? null : thenable._debugInfo;
660 + value = thenable;
661 + }
662 + } else {
663 + value = (actionResult: any);
664 + }
665 + } else {
666 + value = initialState;
667 + }
668 +
669 + hookLog.push({
670 + displayName: null,
671 + primitive: 'ActionState',
672 + stackError: stackError,
673 + value: value,
674 + debugInfo: debugInfo,
675 + });
676 +
677 + if (error !== null) {
678 + throw error;
679 + }
680 +
681 + // value being a Thenable is equivalent to error being not null
682 + // i.e. we only reach this point with Awaited<S>
683 + const state = ((value: any): Awaited<S>);
684 +
685 + // TODO: support displaying pending value
686 + return [state, (payload: P) => {}, false];
687 +}
688 +
689 const Dispatcher: DispatcherType = {
690 use,
691 readContext,
@@ -635,6 +708,7 @@ const Dispatcher: DispatcherType = {
708 useDeferredValue,
709 useId,
710 useFormState,
711 + useActionState,
712 };
713
714 // create a proxy to throw a custom error
packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
+9 -4
@@ -23,7 +23,7 @@ let ReactDOMServer;
23 let ReactDOMClient;
24 let useFormStatus;
25 let useOptimistic;
26 -let useFormState;
26 +let useActionState;
27
28 describe('ReactDOMFizzForm', () => {
29 beforeEach(() => {
@@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => {
32 ReactDOMServer = require('react-dom/server.browser');
33 ReactDOMClient = require('react-dom/client');
34 useFormStatus = require('react-dom').useFormStatus;
35 - useFormState = require('react-dom').useFormState;
35 useOptimistic = require('react').useOptimistic;
36 act = require('internal-test-utils').act;
37 container = document.createElement('div');
38 document.body.appendChild(container);
39 + if (__VARIANT__) {
40 + // Remove after API is deleted.
41 + useActionState = require('react-dom').useFormState;
42 + } else {
43 + useActionState = require('react').useActionState;
44 + }
45 });
46
47 afterEach(() => {
@@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => {
479
480 // @gate enableFormActions
481 // @gate enableAsyncActions
477 - it('useFormState returns initial state', async () => {
482 + it('useActionState returns initial state', async () => {
483 async function action(state) {
484 return state;
485 }
486
487 function App() {
483 - const [state] = useFormState(action, 0);
488 + const [state] = useActionState(action, 0);
489 return state;
490 }
491
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+13 -9
@@ -30,7 +30,7 @@ let SuspenseList;
30 let useSyncExternalStore;
31 let useSyncExternalStoreWithSelector;
32 let use;
33 -let useFormState;
33 +let useActionState;
34 let PropTypes;
35 let textCache;
36 let writable;
@@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => {
89 if (gate(flags => flags.enableSuspenseList)) {
90 SuspenseList = React.unstable_SuspenseList;
91 }
92 - useFormState = ReactDOM.useFormState;
93 -
92 PropTypes = require('prop-types');
93 + if (__VARIANT__) {
94 + // Remove after API is deleted.
95 + useActionState = ReactDOM.useFormState;
96 + } else {
97 + useActionState = React.useActionState;
98 + }
99
100 const InternalTestUtils = require('internal-test-utils');
101 waitForAll = InternalTestUtils.waitForAll;
@@ -6137,8 +6141,8 @@ describe('ReactDOMFizzServer', () => {
6141
6142 // @gate enableFormActions
6143 // @gate enableAsyncActions
6140 - it('useFormState hydrates without a mismatch', async () => {
6141 - // This is testing an implementation detail: useFormState emits comment
6144 + it('useActionState hydrates without a mismatch', async () => {
6145 + // This is testing an implementation detail: useActionState emits comment
6146 // nodes into the SSR stream, so this checks that they are handled correctly
6147 // during hydration.
6148
@@ -6148,7 +6152,7 @@ describe('ReactDOMFizzServer', () => {
6152
6153 const childRef = React.createRef(null);
6154 function Form() {
6151 - const [state] = useFormState(action, 0);
6155 + const [state] = useActionState(action, 0);
6156 const text = `Child: ${state}`;
6157 return (
6158 <div id="child" ref={childRef}>
@@ -6191,7 +6195,7 @@ describe('ReactDOMFizzServer', () => {
6195
6196 // @gate enableFormActions
6197 // @gate enableAsyncActions
6194 - it("useFormState hydrates without a mismatch if there's a render phase update", async () => {
6198 + it("useActionState hydrates without a mismatch if there's a render phase update", async () => {
6199 async function action(state) {
6200 return state;
6201 }
@@ -6205,8 +6209,8 @@ describe('ReactDOMFizzServer', () => {
6209
6210 // Because of the render phase update above, this component is evaluated
6211 // multiple times (even during SSR), but it should only emit a single
6208 - // marker per useFormState instance.
6209 - const [formState] = useFormState(action, 0);
6212 + // marker per useActionState instance.
6213 + const [formState] = useActionState(action, 0);
6214 const text = `${readText('Child')}:${formState}:${localState}`;
6215 return (
6216 <div id="child" ref={childRef}>
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+23 -17
@@ -41,7 +41,7 @@ describe('ReactDOMForm', () => {
41 let startTransition;
42 let textCache;
43 let useFormStatus;
44 - let useFormState;
44 + let useActionState;
45
46 beforeEach(() => {
47 jest.resetModules();
@@ -56,11 +56,17 @@ describe('ReactDOMForm', () => {
56 Suspense = React.Suspense;
57 startTransition = React.startTransition;
58 useFormStatus = ReactDOM.useFormStatus;
59 - useFormState = ReactDOM.useFormState;
59 container = document.createElement('div');
60 document.body.appendChild(container);
61
62 textCache = new Map();
63 +
64 + if (__VARIANT__) {
65 + // Remove after API is deleted.
66 + useActionState = ReactDOM.useFormState;
67 + } else {
68 + useActionState = React.useActionState;
69 + }
70 });
71
72 function resolveText(text) {
@@ -962,7 +968,7 @@ describe('ReactDOMForm', () => {
968
969 // @gate enableFormActions
970 // @gate enableAsyncActions
965 - test('useFormState updates state asynchronously and queues multiple actions', async () => {
971 + test('useActionState updates state asynchronously and queues multiple actions', async () => {
972 let actionCounter = 0;
973 async function action(state, type) {
974 actionCounter++;
@@ -982,7 +988,7 @@ describe('ReactDOMForm', () => {
988
989 let dispatch;
990 function App() {
985 - const [state, _dispatch, isPending] = useFormState(action, 0);
991 + const [state, _dispatch, isPending] = useActionState(action, 0);
992 dispatch = _dispatch;
993 const pending = isPending ? 'Pending ' : '';
994 return <Text text={pending + state} />;
@@ -1023,10 +1029,10 @@ describe('ReactDOMForm', () => {
1029
1030 // @gate enableFormActions
1031 // @gate enableAsyncActions
1026 - test('useFormState supports inline actions', async () => {
1032 + test('useActionState supports inline actions', async () => {
1033 let increment;
1034 function App({stepSize}) {
1029 - const [state, dispatch, isPending] = useFormState(async prevState => {
1035 + const [state, dispatch, isPending] = useActionState(async prevState => {
1036 return prevState + stepSize;
1037 }, 0);
1038 increment = dispatch;
@@ -1056,9 +1062,9 @@ describe('ReactDOMForm', () => {
1062
1063 // @gate enableFormActions
1064 // @gate enableAsyncActions
1059 - test('useFormState: dispatch throws if called during render', async () => {
1065 + test('useActionState: dispatch throws if called during render', async () => {
1066 function App() {
1061 - const [state, dispatch, isPending] = useFormState(async () => {}, 0);
1067 + const [state, dispatch, isPending] = useActionState(async () => {}, 0);
1068 dispatch();
1069 const pending = isPending ? 'Pending ' : '';
1070 return <Text text={pending + state} />;
@@ -1076,7 +1082,7 @@ describe('ReactDOMForm', () => {
1082 test('queues multiple actions and runs them in order', async () => {
1083 let action;
1084 function App() {
1079 - const [state, dispatch, isPending] = useFormState(
1085 + const [state, dispatch, isPending] = useActionState(
1086 async (s, a) => await getText(a),
1087 'A',
1088 );
@@ -1106,10 +1112,10 @@ describe('ReactDOMForm', () => {
1112
1113 // @gate enableFormActions
1114 // @gate enableAsyncActions
1109 - test('useFormState: works if action is sync', async () => {
1115 + test('useActionState: works if action is sync', async () => {
1116 let increment;
1117 function App({stepSize}) {
1112 - const [state, dispatch, isPending] = useFormState(prevState => {
1118 + const [state, dispatch, isPending] = useActionState(prevState => {
1119 return prevState + stepSize;
1120 }, 0);
1121 increment = dispatch;
@@ -1139,10 +1145,10 @@ describe('ReactDOMForm', () => {
1145
1146 // @gate enableFormActions
1147 // @gate enableAsyncActions
1142 - test('useFormState: can mix sync and async actions', async () => {
1148 + test('useActionState: can mix sync and async actions', async () => {
1149 let action;
1150 function App() {
1145 - const [state, dispatch, isPending] = useFormState((s, a) => a, 'A');
1151 + const [state, dispatch, isPending] = useActionState((s, a) => a, 'A');
1152 action = dispatch;
1153 const pending = isPending ? 'Pending ' : '';
1154 return <Text text={pending + state} />;
@@ -1168,7 +1174,7 @@ describe('ReactDOMForm', () => {
1174
1175 // @gate enableFormActions
1176 // @gate enableAsyncActions
1171 - test('useFormState: error handling (sync action)', async () => {
1177 + test('useActionState: error handling (sync action)', async () => {
1178 let resetErrorBoundary;
1179 class ErrorBoundary extends React.Component {
1180 state = {error: null};
@@ -1186,7 +1192,7 @@ describe('ReactDOMForm', () => {
1192
1193 let action;
1194 function App() {
1189 - const [state, dispatch, isPending] = useFormState((s, a) => {
1195 + const [state, dispatch, isPending] = useActionState((s, a) => {
1196 if (a.endsWith('!')) {
1197 throw new Error(a);
1198 }
@@ -1233,7 +1239,7 @@ describe('ReactDOMForm', () => {
1239
1240 // @gate enableFormActions
1241 // @gate enableAsyncActions
1236 - test('useFormState: error handling (async action)', async () => {
1242 + test('useActionState: error handling (async action)', async () => {
1243 let resetErrorBoundary;
1244 class ErrorBoundary extends React.Component {
1245 state = {error: null};
@@ -1251,7 +1257,7 @@ describe('ReactDOMForm', () => {
1257
1258 let action;
1259 function App() {
1254 - const [state, dispatch, isPending] = useFormState(async (s, a) => {
1260 + const [state, dispatch, isPending] = useActionState(async (s, a) => {
1261 const text = await getText(a);
1262 if (text.endsWith('!')) {
1263 throw new Error(text);
packages/react-reconciler/src/ReactFiberHooks.js
+77
@@ -3516,6 +3516,7 @@ if (enableFormActions && enableAsyncActions) {
3516 (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
3517 throwInvalidHookError;
3518 (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
3519 + (ContextOnlyDispatcher: Dispatcher).useActionState = throwInvalidHookError;
3520 }
3521 if (enableAsyncActions) {
3522 (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
@@ -3554,6 +3555,7 @@ if (enableFormActions && enableAsyncActions) {
3555 (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
3556 useHostTransitionStatus;
3557 (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
3558 + (HooksDispatcherOnMount: Dispatcher).useActionState = mountFormState;
3559 }
3560 if (enableAsyncActions) {
3561 (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
@@ -3592,6 +3594,7 @@ if (enableFormActions && enableAsyncActions) {
3594 (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
3595 useHostTransitionStatus;
3596 (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
3597 + (HooksDispatcherOnUpdate: Dispatcher).useActionState = updateFormState;
3598 }
3599 if (enableAsyncActions) {
3600 (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
@@ -3630,6 +3633,7 @@ if (enableFormActions && enableAsyncActions) {
3633 (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
3634 useHostTransitionStatus;
3635 (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
3636 + (HooksDispatcherOnRerender: Dispatcher).useActionState = rerenderFormState;
3637 }
3638 if (enableAsyncActions) {
3639 (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
@@ -3824,6 +3828,16 @@ if (__DEV__) {
3828 mountHookTypesDev();
3829 return mountFormState(action, initialState, permalink);
3830 };
3831 + (HooksDispatcherOnMountInDEV: Dispatcher).useActionState =
3832 + function useActionState<S, P>(
3833 + action: (Awaited<S>, P) => S,
3834 + initialState: Awaited<S>,
3835 + permalink?: string,
3836 + ): [Awaited<S>, (P) => void, boolean] {
3837 + currentHookNameInDev = 'useActionState';
3838 + mountHookTypesDev();
3839 + return mountFormState(action, initialState, permalink);
3840 + };
3841 }
3842 if (enableAsyncActions) {
3843 (HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
@@ -3994,6 +4008,16 @@ if (__DEV__) {
4008 updateHookTypesDev();
4009 return mountFormState(action, initialState, permalink);
4010 };
4011 + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useActionState =
4012 + function useActionState<S, P>(
4013 + action: (Awaited<S>, P) => S,
4014 + initialState: Awaited<S>,
4015 + permalink?: string,
4016 + ): [Awaited<S>, (P) => void, boolean] {
4017 + currentHookNameInDev = 'useActionState';
4018 + updateHookTypesDev();
4019 + return mountFormState(action, initialState, permalink);
4020 + };
4021 }
4022 if (enableAsyncActions) {
4023 (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic =
@@ -4166,6 +4190,16 @@ if (__DEV__) {
4190 updateHookTypesDev();
4191 return updateFormState(action, initialState, permalink);
4192 };
4193 + (HooksDispatcherOnUpdateInDEV: Dispatcher).useActionState =
4194 + function useActionState<S, P>(
4195 + action: (Awaited<S>, P) => S,
4196 + initialState: Awaited<S>,
4197 + permalink?: string,
4198 + ): [Awaited<S>, (P) => void, boolean] {
4199 + currentHookNameInDev = 'useActionState';
4200 + updateHookTypesDev();
4201 + return updateFormState(action, initialState, permalink);
4202 + };
4203 }
4204 if (enableAsyncActions) {
4205 (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
@@ -4338,6 +4372,16 @@ if (__DEV__) {
4372 updateHookTypesDev();
4373 return rerenderFormState(action, initialState, permalink);
4374 };
4375 + (HooksDispatcherOnRerenderInDEV: Dispatcher).useActionState =
4376 + function useActionState<S, P>(
4377 + action: (Awaited<S>, P) => S,
4378 + initialState: Awaited<S>,
4379 + permalink?: string,
4380 + ): [Awaited<S>, (P) => void, boolean] {
4381 + currentHookNameInDev = 'useActionState';
4382 + updateHookTypesDev();
4383 + return rerenderFormState(action, initialState, permalink);
4384 + };
4385 }
4386 if (enableAsyncActions) {
4387 (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
@@ -4532,6 +4576,17 @@ if (__DEV__) {
4576 mountHookTypesDev();
4577 return mountFormState(action, initialState, permalink);
4578 };
4579 + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useActionState =
4580 + function useActionState<S, P>(
4581 + action: (Awaited<S>, P) => S,
4582 + initialState: Awaited<S>,
4583 + permalink?: string,
4584 + ): [Awaited<S>, (P) => void, boolean] {
4585 + currentHookNameInDev = 'useActionState';
4586 + warnInvalidHookAccess();
4587 + mountHookTypesDev();
4588 + return mountFormState(action, initialState, permalink);
4589 + };
4590 }
4591 if (enableAsyncActions) {
4592 (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
@@ -4730,6 +4785,17 @@ if (__DEV__) {
4785 updateHookTypesDev();
4786 return updateFormState(action, initialState, permalink);
4787 };
4788 + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useActionState =
4789 + function useActionState<S, P>(
4790 + action: (Awaited<S>, P) => S,
4791 + initialState: Awaited<S>,
4792 + permalink?: string,
4793 + ): [Awaited<S>, (P) => void, boolean] {
4794 + currentHookNameInDev = 'useActionState';
4795 + warnInvalidHookAccess();
4796 + updateHookTypesDev();
4797 + return updateFormState(action, initialState, permalink);
4798 + };
4799 }
4800 if (enableAsyncActions) {
4801 (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
@@ -4928,6 +4994,17 @@ if (__DEV__) {
4994 updateHookTypesDev();
4995 return rerenderFormState(action, initialState, permalink);
4996 };
4997 + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useActionState =
4998 + function useActionState<S, P>(
4999 + action: (Awaited<S>, P) => S,
5000 + initialState: Awaited<S>,
5001 + permalink?: string,
5002 + ): [Awaited<S>, (P) => void, boolean] {
5003 + currentHookNameInDev = 'useActionState';
5004 + warnInvalidHookAccess();
5005 + updateHookTypesDev();
5006 + return rerenderFormState(action, initialState, permalink);
5007 + };
5008 }
5009 if (enableAsyncActions) {
5010 (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
packages/react-reconciler/src/ReactInternalTypes.js
+7 -1
@@ -56,7 +56,8 @@ export type HookType =
56 | 'useId'
57 | 'useCacheRefresh'
58 | 'useOptimistic'
59 - | 'useFormState';
59 + | 'useFormState'
60 + | 'useActionState';
61
62 export type ContextDependency<T> = {
63 context: ReactContext<T>,
@@ -414,6 +415,11 @@ export type Dispatcher = {
415 initialState: Awaited<S>,
416 permalink?: string,
417 ) => [Awaited<S>, (P) => void, boolean],
418 + useActionState?: <S, P>(
419 + action: (Awaited<S>, P) => S,
420 + initialState: Awaited<S>,
421 + permalink?: string,
422 + ) => [Awaited<S>, (P) => void, boolean],
423 };
424
425 export type CacheDispatcher = {
packages/react-refresh/src/ReactFreshBabelPlugin.js
+2
@@ -244,6 +244,8 @@ export default function (babel, opts = {}) {
244 case 'React.useFormStatus':
245 case 'useFormState':
246 case 'React.useFormState':
247 + case 'useActionState':
248 + case 'React.useActionState':
249 case 'useOptimistic':
250 case 'React.useOptimistic':
251 return true;
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+22 -16
@@ -31,7 +31,7 @@ let ReactDOMServer;
31 let ReactServerDOMServer;
32 let ReactServerDOMClient;
33 let ReactDOMClient;
34 -let useFormState;
34 +let useActionState;
35 let act;
36
37 describe('ReactFlightDOMForm', () => {
@@ -55,7 +55,13 @@ describe('ReactFlightDOMForm', () => {
55 ReactDOMServer = require('react-dom/server.edge');
56 ReactDOMClient = require('react-dom/client');
57 act = React.act;
58 - useFormState = require('react-dom').useFormState;
58 +
59 + if (__VARIANT__) {
60 + // Remove after API is deleted.
61 + useActionState = require('react-dom').useFormState;
62 + } else {
63 + useActionState = require('react').useActionState;
64 + }
65 container = document.createElement('div');
66 document.body.appendChild(container);
67 });
@@ -346,7 +352,7 @@ describe('ReactFlightDOMForm', () => {
352
353 // @gate enableFormActions
354 // @gate enableAsyncActions
349 - it("useFormState's dispatch binds the initial state to the provided action", async () => {
355 + it("useActionState's dispatch binds the initial state to the provided action", async () => {
356 const serverAction = serverExports(
357 async function action(prevState, formData) {
358 return {
@@ -358,7 +364,7 @@ describe('ReactFlightDOMForm', () => {
364
365 const initialState = {count: 1};
366 function Client({action}) {
361 - const [state, dispatch, isPending] = useFormState(action, initialState);
367 + const [state, dispatch, isPending] = useActionState(action, initialState);
368 return (
369 <form action={dispatch}>
370 <span>{isPending ? 'Pending...' : ''}</span>
@@ -395,7 +401,7 @@ describe('ReactFlightDOMForm', () => {
401
402 // @gate enableFormActions
403 // @gate enableAsyncActions
398 - it('useFormState can reuse state during MPA form submission', async () => {
404 + it('useActionState can reuse state during MPA form submission', async () => {
405 const serverAction = serverExports(
406 async function action(prevState, formData) {
407 return prevState + 1;
@@ -403,7 +409,7 @@ describe('ReactFlightDOMForm', () => {
409 );
410
411 function Form({action}) {
406 - const [count, dispatch, isPending] = useFormState(action, 1);
412 + const [count, dispatch, isPending] = useActionState(action, 1);
413 return (
414 <form action={dispatch}>
415 {isPending ? 'Pending...' : ''}
@@ -486,7 +492,7 @@ describe('ReactFlightDOMForm', () => {
492 // @gate enableFormActions
493 // @gate enableAsyncActions
494 it(
489 - 'useFormState preserves state if arity is the same, but different ' +
495 + 'useActionState preserves state if arity is the same, but different ' +
496 'arguments are bound (i.e. inline closure)',
497 async () => {
498 const serverAction = serverExports(
@@ -496,7 +502,7 @@ describe('ReactFlightDOMForm', () => {
502 );
503
504 function Form({action}) {
499 - const [count, dispatch, isPending] = useFormState(action, 1);
505 + const [count, dispatch, isPending] = useActionState(action, 1);
506 return (
507 <form action={dispatch}>
508 {isPending ? 'Pending...' : ''}
@@ -605,7 +611,7 @@ describe('ReactFlightDOMForm', () => {
611
612 // @gate enableFormActions
613 // @gate enableAsyncActions
608 - it('useFormState does not reuse state if action signatures are different', async () => {
614 + it('useActionState does not reuse state if action signatures are different', async () => {
615 // This is the same as the previous test, except instead of using bind to
616 // configure the server action (i.e. a closure), it swaps the action.
617 const increaseBy1 = serverExports(
@@ -621,7 +627,7 @@ describe('ReactFlightDOMForm', () => {
627 );
628
629 function Form({action}) {
624 - const [count, dispatch, isPending] = useFormState(action, 1);
630 + const [count, dispatch, isPending] = useActionState(action, 1);
631 return (
632 <form action={dispatch}>
633 {isPending ? 'Pending...' : ''}
@@ -693,7 +699,7 @@ describe('ReactFlightDOMForm', () => {
699
700 // @gate enableFormActions
701 // @gate enableAsyncActions
696 - it('when permalink is provided, useFormState compares that instead of the keypath', async () => {
702 + it('when permalink is provided, useActionState compares that instead of the keypath', async () => {
703 const serverAction = serverExports(
704 async function action(prevState, formData) {
705 return prevState + 1;
@@ -701,7 +707,7 @@ describe('ReactFlightDOMForm', () => {
707 );
708
709 function Form({action, permalink}) {
704 - const [count, dispatch, isPending] = useFormState(action, 1, permalink);
710 + const [count, dispatch, isPending] = useActionState(action, 1, permalink);
711 return (
712 <form action={dispatch}>
713 {isPending ? 'Pending...' : ''}
@@ -800,14 +806,14 @@ describe('ReactFlightDOMForm', () => {
806
807 // @gate enableFormActions
808 // @gate enableAsyncActions
803 - it('useFormState can change the action URL with the `permalink` argument', async () => {
809 + it('useActionState can change the action URL with the `permalink` argument', async () => {
810 const serverAction = serverExports(function action(prevState) {
811 return {state: prevState.count + 1};
812 });
813
814 const initialState = {count: 1};
815 function Client({action}) {
810 - const [state, dispatch, isPending] = useFormState(
816 + const [state, dispatch, isPending] = useActionState(
817 action,
818 initialState,
819 '/permalink',
@@ -846,7 +852,7 @@ describe('ReactFlightDOMForm', () => {
852
853 // @gate enableFormActions
854 // @gate enableAsyncActions
849 - it('useFormState `permalink` is coerced to string', async () => {
855 + it('useActionState `permalink` is coerced to string', async () => {
856 const serverAction = serverExports(function action(prevState) {
857 return {state: prevState.count + 1};
858 });
@@ -861,7 +867,7 @@ describe('ReactFlightDOMForm', () => {
867
868 const initialState = {count: 1};
869 function Client({action}) {
864 - const [state, dispatch, isPending] = useFormState(
870 + const [state, dispatch, isPending] = useActionState(
871 action,
872 initialState,
873 permalink,
packages/react-server/src/ReactFizzHooks.js
+1
@@ -820,6 +820,7 @@ if (enableFormActions && enableAsyncActions) {
820 if (enableAsyncActions) {
821 HooksDispatcher.useOptimistic = useOptimistic;
822 HooksDispatcher.useFormState = useFormState;
823 + HooksDispatcher.useActionState = useFormState;
824 }
825
826 export let currentResumableState: null | ResumableState = (null: any);
packages/react/index.classic.fb.js
+1
@@ -57,6 +57,7 @@ export {
57 useState,
58 useSyncExternalStore,
59 useTransition,
60 + useActionState,
61 version,
62 } from './src/ReactClient';
63 export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX';
packages/react/index.experimental.js
+1
@@ -55,6 +55,7 @@ export {
55 useState,
56 useSyncExternalStore,
57 useTransition,
58 + useActionState,
59 version,
60 } from './src/ReactClient';
61
packages/react/index.js
+1
@@ -78,5 +78,6 @@ export {
78 useRef,
79 useState,
80 useTransition,
81 + useActionState,
82 version,
83 } from './src/ReactClient';
packages/react/index.modern.fb.js
+1
@@ -55,6 +55,7 @@ export {
55 useState,
56 useSyncExternalStore,
57 useTransition,
58 + useActionState,
59 version,
60 } from './src/ReactClient';
61 export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX';
packages/react/index.stable.js
+1
@@ -46,5 +46,6 @@ export {
46 useState,
47 useSyncExternalStore,
48 useTransition,
49 + useActionState,
50 version,
51 } from './src/ReactClient';
packages/react/src/ReactClient.js
+2
@@ -60,6 +60,7 @@ import {
60 use,
61 useMemoCache,
62 useOptimistic,
63 + useActionState,
64 } from './ReactHooks';
65
66 import ReactSharedInternals from './ReactSharedInternalsClient';
@@ -95,6 +96,7 @@ export {
96 useLayoutEffect,
97 useMemo,
98 useOptimistic,
99 + useActionState,
100 useSyncExternalStore,
101 useReducer,
102 useRef,
packages/react/src/ReactHooks.js
+16
@@ -12,11 +12,13 @@ import type {
12 ReactContext,
13 StartTransitionOptions,
14 Usable,
15 + Awaited,
16 } from 'shared/ReactTypes';
17 import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols';
18
19 import ReactCurrentDispatcher from './ReactCurrentDispatcher';
20 import ReactCurrentCache from './ReactCurrentCache';
21 +import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
22
23 type BasicStateAction<S> = (S => S) | S;
24 type Dispatch<A> = A => void;
@@ -227,3 +229,17 @@ export function useOptimistic<S, A>(
229 // $FlowFixMe[not-a-function] This is unstable, thus optional
230 return dispatcher.useOptimistic(passthrough, reducer);
231 }
232 +
233 +export function useActionState<S, P>(
234 + action: (Awaited<S>, P) => S,
235 + initialState: Awaited<S>,
236 + permalink?: string,
237 +): [Awaited<S>, (P) => void, boolean] {
238 + if (!(enableFormActions && enableAsyncActions)) {
239 + throw new Error('Not implemented.');
240 + } else {
241 + const dispatcher = resolveDispatcher();
242 + // $FlowFixMe[not-a-function] This is unstable, thus optional
243 + return dispatcher.useActionState(action, initialState, permalink);
244 + }
245 +}
packages/react/src/ReactServer.experimental.js
+2
@@ -34,6 +34,7 @@ import {
34 useCallback,
35 useDebugValue,
36 useMemo,
37 + useActionState,
38 getCacheSignal,
39 getCacheForType,
40 } from './ReactHooks';
@@ -84,5 +85,6 @@ export {
85 useCallback,
86 useDebugValue,
87 useMemo,
88 + useActionState,
89 version,
90 };
packages/react/src/ReactServer.js
+9 -1
@@ -27,7 +27,14 @@ import {
27 isValidElement,
28 } from './jsx/ReactJSXElement';
29 import {createRef} from './ReactCreateRef';
30 -import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
30 +import {
31 + use,
32 + useId,
33 + useCallback,
34 + useDebugValue,
35 + useMemo,
36 + useActionState,
37 +} from './ReactHooks';
38 import {forwardRef} from './ReactForwardRef';
39 import {lazy} from './ReactLazy';
40 import {memo} from './ReactMemo';
@@ -63,5 +70,6 @@ export {
70 useCallback,
71 useDebugValue,
72 useMemo,
73 + useActionState,
74 version,
75 };