From a6095032a03de5ca3b0acad34f9dd341a2de1dd0 Mon Sep 17 00:00:00 2001 From: Ricky Date: Fri, 22 Mar 2024 13:03:44 -0400 Subject: [PATCH] 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 `
` actions, similar to `useFormStatus` (which is only for `` 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 `` 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 `` 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 ``, 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( action: (state: Awaited) => State | Promise, initialState: Awaited, permalink?: string, ): [state: Awaited, 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 ( {state.errorMessage &&

{state.errorMessage}

} ); } ``` But it doesn't need to be used with a `
` (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 (
{state.errorMessage &&

{state.errorMessage}

}
); } ``` ## 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 (
{state.errorMessage &&

{state.errorMessage}

}
); } ``` However, this hook adds more benefits when used with render specific elements like react-dom `` elements and Server Action. With `` 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 (
{state.errorMessage &&

{state.errorMessage}

}
); } ``` 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 (
{state.errorMessage &&

{state.errorMessage}

}
); } ``` 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 (
{state.errorMessage &&

{state.errorMessage}

}
); } ``` 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. --- .../react-debug-tools/src/ReactDebugHooks.js | 74 ++++++++++++++++++ .../src/__tests__/ReactDOMFizzForm-test.js | 13 +++- .../src/__tests__/ReactDOMFizzServer-test.js | 22 +++--- .../src/__tests__/ReactDOMForm-test.js | 40 ++++++---- .../react-reconciler/src/ReactFiberHooks.js | 77 +++++++++++++++++++ .../src/ReactInternalTypes.js | 8 +- .../src/ReactFreshBabelPlugin.js | 2 + .../src/__tests__/ReactFlightDOMForm-test.js | 38 +++++---- packages/react-server/src/ReactFizzHooks.js | 1 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/index.stable.js | 1 + packages/react/src/ReactClient.js | 2 + packages/react/src/ReactHooks.js | 16 ++++ .../react/src/ReactServer.experimental.js | 2 + packages/react/src/ReactServer.js | 10 ++- 18 files changed, 262 insertions(+), 48 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index a6770ccd34bfa..8d7477490da21 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -106,6 +106,10 @@ function getPrimitiveStackCache(): Map> { // This type check is for Flow only. Dispatcher.useFormState((s: mixed, p: mixed) => s, null); } + if (typeof Dispatcher.useActionState === 'function') { + // This type check is for Flow only. + Dispatcher.useActionState((s: mixed, p: mixed) => s, null); + } if (typeof Dispatcher.use === 'function') { // This type check is for Flow only. Dispatcher.use( @@ -613,6 +617,75 @@ function useFormState( return [state, (payload: P) => {}, false]; } +function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, +): [Awaited, (P) => void, boolean] { + const hook = nextHook(); // FormState + nextHook(); // PendingState + nextHook(); // ActionQueue + const stackError = new Error(); + let value; + let debugInfo = null; + let error = null; + + if (hook !== null) { + const actionResult = hook.memoizedState; + if ( + typeof actionResult === 'object' && + actionResult !== null && + // $FlowFixMe[method-unbinding] + typeof actionResult.then === 'function' + ) { + const thenable: Thenable> = (actionResult: any); + switch (thenable.status) { + case 'fulfilled': { + value = thenable.value; + debugInfo = + thenable._debugInfo === undefined ? null : thenable._debugInfo; + break; + } + case 'rejected': { + const rejectedError = thenable.reason; + error = rejectedError; + break; + } + default: + // If this was an uncached Promise we have to abandon this attempt + // but we can still emit anything up until this point. + error = SuspenseException; + debugInfo = + thenable._debugInfo === undefined ? null : thenable._debugInfo; + value = thenable; + } + } else { + value = (actionResult: any); + } + } else { + value = initialState; + } + + hookLog.push({ + displayName: null, + primitive: 'ActionState', + stackError: stackError, + value: value, + debugInfo: debugInfo, + }); + + if (error !== null) { + throw error; + } + + // value being a Thenable is equivalent to error being not null + // i.e. we only reach this point with Awaited + const state = ((value: any): Awaited); + + // TODO: support displaying pending value + return [state, (payload: P) => {}, false]; +} + const Dispatcher: DispatcherType = { use, readContext, @@ -635,6 +708,7 @@ const Dispatcher: DispatcherType = { useDeferredValue, useId, useFormState, + useActionState, }; // create a proxy to throw a custom error diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index ae56c5eae4dd2..ad21f230ed380 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -23,7 +23,7 @@ let ReactDOMServer; let ReactDOMClient; let useFormStatus; let useOptimistic; -let useFormState; +let useActionState; describe('ReactDOMFizzForm', () => { beforeEach(() => { @@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => { ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); useFormStatus = require('react-dom').useFormStatus; - useFormState = require('react-dom').useFormState; useOptimistic = require('react').useOptimistic; act = require('internal-test-utils').act; container = document.createElement('div'); document.body.appendChild(container); + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = require('react-dom').useFormState; + } else { + useActionState = require('react').useActionState; + } }); afterEach(() => { @@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState returns initial state', async () => { + it('useActionState returns initial state', async () => { async function action(state) { return state; } function App() { - const [state] = useFormState(action, 0); + const [state] = useActionState(action, 0); return state; } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 546a7ee634524..609d24b6fa789 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -30,7 +30,7 @@ let SuspenseList; let useSyncExternalStore; let useSyncExternalStoreWithSelector; let use; -let useFormState; +let useActionState; let PropTypes; let textCache; let writable; @@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => { if (gate(flags => flags.enableSuspenseList)) { SuspenseList = React.unstable_SuspenseList; } - useFormState = ReactDOM.useFormState; - PropTypes = require('prop-types'); + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = ReactDOM.useFormState; + } else { + useActionState = React.useActionState; + } const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; @@ -6137,8 +6141,8 @@ describe('ReactDOMFizzServer', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState hydrates without a mismatch', async () => { - // This is testing an implementation detail: useFormState emits comment + it('useActionState hydrates without a mismatch', async () => { + // This is testing an implementation detail: useActionState emits comment // nodes into the SSR stream, so this checks that they are handled correctly // during hydration. @@ -6148,7 +6152,7 @@ describe('ReactDOMFizzServer', () => { const childRef = React.createRef(null); function Form() { - const [state] = useFormState(action, 0); + const [state] = useActionState(action, 0); const text = `Child: ${state}`; return (
@@ -6191,7 +6195,7 @@ describe('ReactDOMFizzServer', () => { // @gate enableFormActions // @gate enableAsyncActions - it("useFormState hydrates without a mismatch if there's a render phase update", async () => { + it("useActionState hydrates without a mismatch if there's a render phase update", async () => { async function action(state) { return state; } @@ -6205,8 +6209,8 @@ describe('ReactDOMFizzServer', () => { // Because of the render phase update above, this component is evaluated // multiple times (even during SSR), but it should only emit a single - // marker per useFormState instance. - const [formState] = useFormState(action, 0); + // marker per useActionState instance. + const [formState] = useActionState(action, 0); const text = `${readText('Child')}:${formState}:${localState}`; return (
diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index ea97b293ff707..b0d66a9e94c92 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -41,7 +41,7 @@ describe('ReactDOMForm', () => { let startTransition; let textCache; let useFormStatus; - let useFormState; + let useActionState; beforeEach(() => { jest.resetModules(); @@ -56,11 +56,17 @@ describe('ReactDOMForm', () => { Suspense = React.Suspense; startTransition = React.startTransition; useFormStatus = ReactDOM.useFormStatus; - useFormState = ReactDOM.useFormState; container = document.createElement('div'); document.body.appendChild(container); textCache = new Map(); + + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = ReactDOM.useFormState; + } else { + useActionState = React.useActionState; + } }); function resolveText(text) { @@ -962,7 +968,7 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState updates state asynchronously and queues multiple actions', async () => { + test('useActionState updates state asynchronously and queues multiple actions', async () => { let actionCounter = 0; async function action(state, type) { actionCounter++; @@ -982,7 +988,7 @@ describe('ReactDOMForm', () => { let dispatch; function App() { - const [state, _dispatch, isPending] = useFormState(action, 0); + const [state, _dispatch, isPending] = useActionState(action, 0); dispatch = _dispatch; const pending = isPending ? 'Pending ' : ''; return ; @@ -1023,10 +1029,10 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState supports inline actions', async () => { + test('useActionState supports inline actions', async () => { let increment; function App({stepSize}) { - const [state, dispatch, isPending] = useFormState(async prevState => { + const [state, dispatch, isPending] = useActionState(async prevState => { return prevState + stepSize; }, 0); increment = dispatch; @@ -1056,9 +1062,9 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: dispatch throws if called during render', async () => { + test('useActionState: dispatch throws if called during render', async () => { function App() { - const [state, dispatch, isPending] = useFormState(async () => {}, 0); + const [state, dispatch, isPending] = useActionState(async () => {}, 0); dispatch(); const pending = isPending ? 'Pending ' : ''; return ; @@ -1076,7 +1082,7 @@ describe('ReactDOMForm', () => { test('queues multiple actions and runs them in order', async () => { let action; function App() { - const [state, dispatch, isPending] = useFormState( + const [state, dispatch, isPending] = useActionState( async (s, a) => await getText(a), 'A', ); @@ -1106,10 +1112,10 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: works if action is sync', async () => { + test('useActionState: works if action is sync', async () => { let increment; function App({stepSize}) { - const [state, dispatch, isPending] = useFormState(prevState => { + const [state, dispatch, isPending] = useActionState(prevState => { return prevState + stepSize; }, 0); increment = dispatch; @@ -1139,10 +1145,10 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: can mix sync and async actions', async () => { + test('useActionState: can mix sync and async actions', async () => { let action; function App() { - const [state, dispatch, isPending] = useFormState((s, a) => a, 'A'); + const [state, dispatch, isPending] = useActionState((s, a) => a, 'A'); action = dispatch; const pending = isPending ? 'Pending ' : ''; return ; @@ -1168,7 +1174,7 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: error handling (sync action)', async () => { + test('useActionState: error handling (sync action)', async () => { let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; @@ -1186,7 +1192,7 @@ describe('ReactDOMForm', () => { let action; function App() { - const [state, dispatch, isPending] = useFormState((s, a) => { + const [state, dispatch, isPending] = useActionState((s, a) => { if (a.endsWith('!')) { throw new Error(a); } @@ -1233,7 +1239,7 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: error handling (async action)', async () => { + test('useActionState: error handling (async action)', async () => { let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; @@ -1251,7 +1257,7 @@ describe('ReactDOMForm', () => { let action; function App() { - const [state, dispatch, isPending] = useFormState(async (s, a) => { + const [state, dispatch, isPending] = useActionState(async (s, a) => { const text = await getText(a); if (text.endsWith('!')) { throw new Error(text); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index e430e3e15b423..e586e00218d5e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -3516,6 +3516,7 @@ if (enableFormActions && enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError; + (ContextOnlyDispatcher: Dispatcher).useActionState = throwInvalidHookError; } if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError; @@ -3554,6 +3555,7 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState; + (HooksDispatcherOnMount: Dispatcher).useActionState = mountFormState; } if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic; @@ -3592,6 +3594,7 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState; + (HooksDispatcherOnUpdate: Dispatcher).useActionState = updateFormState; } if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic; @@ -3630,6 +3633,7 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState; + (HooksDispatcherOnRerender: Dispatcher).useActionState = rerenderFormState; } if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic; @@ -3824,6 +3828,16 @@ if (__DEV__) { mountHookTypesDev(); return mountFormState(action, initialState, permalink); }; + (HooksDispatcherOnMountInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + mountHookTypesDev(); + return mountFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic = @@ -3994,6 +4008,16 @@ if (__DEV__) { updateHookTypesDev(); return mountFormState(action, initialState, permalink); }; + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + updateHookTypesDev(); + return mountFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic = @@ -4166,6 +4190,16 @@ if (__DEV__) { updateHookTypesDev(); return updateFormState(action, initialState, permalink); }; + (HooksDispatcherOnUpdateInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + updateHookTypesDev(); + return updateFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic = @@ -4338,6 +4372,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderFormState(action, initialState, permalink); }; + (HooksDispatcherOnRerenderInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + updateHookTypesDev(); + return rerenderFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic = @@ -4532,6 +4576,17 @@ if (__DEV__) { mountHookTypesDev(); return mountFormState(action, initialState, permalink); }; + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic = @@ -4730,6 +4785,17 @@ if (__DEV__) { updateHookTypesDev(); return updateFormState(action, initialState, permalink); }; + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic = @@ -4928,6 +4994,17 @@ if (__DEV__) { updateHookTypesDev(); return rerenderFormState(action, initialState, permalink); }; + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return rerenderFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic = diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 8d3e99b986cfb..398a5720abf5f 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -56,7 +56,8 @@ export type HookType = | 'useId' | 'useCacheRefresh' | 'useOptimistic' - | 'useFormState'; + | 'useFormState' + | 'useActionState'; export type ContextDependency = { context: ReactContext, @@ -414,6 +415,11 @@ export type Dispatcher = { initialState: Awaited, permalink?: string, ) => [Awaited, (P) => void, boolean], + useActionState?: ( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ) => [Awaited, (P) => void, boolean], }; export type CacheDispatcher = { diff --git a/packages/react-refresh/src/ReactFreshBabelPlugin.js b/packages/react-refresh/src/ReactFreshBabelPlugin.js index fa8ba43f16062..9592a994399e5 100644 --- a/packages/react-refresh/src/ReactFreshBabelPlugin.js +++ b/packages/react-refresh/src/ReactFreshBabelPlugin.js @@ -244,6 +244,8 @@ export default function (babel, opts = {}) { case 'React.useFormStatus': case 'useFormState': case 'React.useFormState': + case 'useActionState': + case 'React.useActionState': case 'useOptimistic': case 'React.useOptimistic': return true; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js index 1b1b589d54b03..bff9fdabcfd0d 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -31,7 +31,7 @@ let ReactDOMServer; let ReactServerDOMServer; let ReactServerDOMClient; let ReactDOMClient; -let useFormState; +let useActionState; let act; describe('ReactFlightDOMForm', () => { @@ -55,7 +55,13 @@ describe('ReactFlightDOMForm', () => { ReactDOMServer = require('react-dom/server.edge'); ReactDOMClient = require('react-dom/client'); act = React.act; - useFormState = require('react-dom').useFormState; + + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = require('react-dom').useFormState; + } else { + useActionState = require('react').useActionState; + } container = document.createElement('div'); document.body.appendChild(container); }); @@ -346,7 +352,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it("useFormState's dispatch binds the initial state to the provided action", async () => { + it("useActionState's dispatch binds the initial state to the provided action", async () => { const serverAction = serverExports( async function action(prevState, formData) { return { @@ -358,7 +364,7 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch, isPending] = useFormState(action, initialState); + const [state, dispatch, isPending] = useActionState(action, initialState); return ( {isPending ? 'Pending...' : ''} @@ -395,7 +401,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState can reuse state during MPA form submission', async () => { + it('useActionState can reuse state during MPA form submission', async () => { const serverAction = serverExports( async function action(prevState, formData) { return prevState + 1; @@ -403,7 +409,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch, isPending] = useFormState(action, 1); + const [count, dispatch, isPending] = useActionState(action, 1); return ( {isPending ? 'Pending...' : ''} @@ -486,7 +492,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions it( - 'useFormState preserves state if arity is the same, but different ' + + 'useActionState preserves state if arity is the same, but different ' + 'arguments are bound (i.e. inline closure)', async () => { const serverAction = serverExports( @@ -496,7 +502,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch, isPending] = useFormState(action, 1); + const [count, dispatch, isPending] = useActionState(action, 1); return ( {isPending ? 'Pending...' : ''} @@ -605,7 +611,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState does not reuse state if action signatures are different', async () => { + it('useActionState does not reuse state if action signatures are different', async () => { // This is the same as the previous test, except instead of using bind to // configure the server action (i.e. a closure), it swaps the action. const increaseBy1 = serverExports( @@ -621,7 +627,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch, isPending] = useFormState(action, 1); + const [count, dispatch, isPending] = useActionState(action, 1); return ( {isPending ? 'Pending...' : ''} @@ -693,7 +699,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('when permalink is provided, useFormState compares that instead of the keypath', async () => { + it('when permalink is provided, useActionState compares that instead of the keypath', async () => { const serverAction = serverExports( async function action(prevState, formData) { return prevState + 1; @@ -701,7 +707,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action, permalink}) { - const [count, dispatch, isPending] = useFormState(action, 1, permalink); + const [count, dispatch, isPending] = useActionState(action, 1, permalink); return ( {isPending ? 'Pending...' : ''} @@ -800,14 +806,14 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState can change the action URL with the `permalink` argument', async () => { + it('useActionState can change the action URL with the `permalink` argument', async () => { const serverAction = serverExports(function action(prevState) { return {state: prevState.count + 1}; }); const initialState = {count: 1}; function Client({action}) { - const [state, dispatch, isPending] = useFormState( + const [state, dispatch, isPending] = useActionState( action, initialState, '/permalink', @@ -846,7 +852,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState `permalink` is coerced to string', async () => { + it('useActionState `permalink` is coerced to string', async () => { const serverAction = serverExports(function action(prevState) { return {state: prevState.count + 1}; }); @@ -861,7 +867,7 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch, isPending] = useFormState( + const [state, dispatch, isPending] = useActionState( action, initialState, permalink, diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index efcb05bcba941..1f3e632d31474 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -820,6 +820,7 @@ if (enableFormActions && enableAsyncActions) { if (enableAsyncActions) { HooksDispatcher.useOptimistic = useOptimistic; HooksDispatcher.useFormState = useFormState; + HooksDispatcher.useActionState = useFormState; } export let currentResumableState: null | ResumableState = (null: any); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 7c84775bb6cc3..949a23c70ab33 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -57,6 +57,7 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX'; diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index dcb695e438a19..30472a43d33f6 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -55,6 +55,7 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; diff --git a/packages/react/index.js b/packages/react/index.js index ac3b45c3a7ca8..a3bd5737066d9 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -78,5 +78,6 @@ export { useRef, useState, useTransition, + useActionState, version, } from './src/ReactClient'; diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 4f73dded7f7eb..d5d9a10f4ca93 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -55,6 +55,7 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX'; diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 2997a62b4a44a..ebab580542b24 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -46,5 +46,6 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index ff988a2a2cf3d..711afdad469de 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -60,6 +60,7 @@ import { use, useMemoCache, useOptimistic, + useActionState, } from './ReactHooks'; import ReactSharedInternals from './ReactSharedInternalsClient'; @@ -95,6 +96,7 @@ export { useLayoutEffect, useMemo, useOptimistic, + useActionState, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index dcdd8c95628d4..faeb0e8b4dcdd 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -12,11 +12,13 @@ import type { ReactContext, StartTransitionOptions, Usable, + Awaited, } from 'shared/ReactTypes'; import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; import ReactCurrentCache from './ReactCurrentCache'; +import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags'; type BasicStateAction = (S => S) | S; type Dispatch = A => void; @@ -229,3 +231,17 @@ export function useOptimistic( // $FlowFixMe[not-a-function] This is unstable, thus optional return dispatcher.useOptimistic(passthrough, reducer); } + +export function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, +): [Awaited, (P) => void, boolean] { + if (!(enableFormActions && enableAsyncActions)) { + throw new Error('Not implemented.'); + } else { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useActionState(action, initialState, permalink); + } +} diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js index ccc6de20eca34..fb90fd18e7b73 100644 --- a/packages/react/src/ReactServer.experimental.js +++ b/packages/react/src/ReactServer.experimental.js @@ -34,6 +34,7 @@ import { useCallback, useDebugValue, useMemo, + useActionState, getCacheSignal, getCacheForType, } from './ReactHooks'; @@ -84,5 +85,6 @@ export { useCallback, useDebugValue, useMemo, + useActionState, version, }; diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js index cc7e2c1847e5a..0c5eacbba23d0 100644 --- a/packages/react/src/ReactServer.js +++ b/packages/react/src/ReactServer.js @@ -27,7 +27,14 @@ import { isValidElement, } from './jsx/ReactJSXElement'; import {createRef} from './ReactCreateRef'; -import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks'; +import { + use, + useId, + useCallback, + useDebugValue, + useMemo, + useActionState, +} from './ReactHooks'; import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; @@ -63,5 +70,6 @@ export { useCallback, useDebugValue, useMemo, + useActionState, version, };