diff --git a/.eslintrc.js b/.eslintrc.js
index 941c2e3b23ca8..6b6c9549bc7c4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -456,6 +456,7 @@ module.exports = {
$ReadOnlyArray: 'readonly',
$ArrayBufferView: 'readonly',
$Shape: 'readonly',
+ ReturnType: 'readonly',
AnimationFrameID: 'readonly',
// For Flow type annotation. Only `BigInt` is valid at runtime.
bigint: 'readonly',
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
index b13eb90304a34..eaedd152d91c6 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
@@ -23,6 +23,7 @@ let ReactDOMServer;
let ReactDOMClient;
let useFormStatus;
let useOptimistic;
+let useFormState;
describe('ReactDOMFizzForm', () => {
beforeEach(() => {
@@ -32,6 +33,7 @@ describe('ReactDOMFizzForm', () => {
ReactDOMClient = require('react-dom/client');
useFormStatus = require('react-dom').experimental_useFormStatus;
useOptimistic = require('react').experimental_useOptimistic;
+ useFormState = require('react').experimental_useFormState;
act = require('internal-test-utils').act;
container = document.createElement('div');
document.body.appendChild(container);
@@ -470,6 +472,27 @@ describe('ReactDOMFizzForm', () => {
expect(container.textContent).toBe('hi');
});
+ // @gate enableAsyncActions
+ it('useFormState returns initial state', async () => {
+ async function action(state) {
+ return state;
+ }
+
+ function App() {
+ const [state] = useFormState(action, 0);
+ return state;
+ }
+
+ const stream = await ReactDOMServer.renderToReadableStream();
+ await readIntoContainer(stream);
+ expect(container.textContent).toBe('0');
+
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ expect(container.textContent).toBe('0');
+ });
+
// @gate enableFormActions
it('can provide a custom action on the server for actions', async () => {
const ref = React.createRef();
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index 77e9edebe7c67..53d2325ceb439 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -1835,6 +1835,37 @@ function rerenderOptimistic(
return [passthrough, dispatch];
}
+function TODO_formStateDispatch() {
+ throw new Error('Not implemented.');
+}
+
+function mountFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+): [S, (P) => void] {
+ // TODO: Not yet implemented
+ return [initialState, TODO_formStateDispatch];
+}
+
+function updateFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+): [S, (P) => void] {
+ // TODO: Not yet implemented
+ return [initialState, TODO_formStateDispatch];
+}
+
+function rerenderFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+): [S, (P) => void] {
+ // TODO: Not yet implemented
+ return [initialState, TODO_formStateDispatch];
+}
+
function pushEffect(
tag: HookFlags,
create: () => (() => void) | void,
@@ -2984,6 +3015,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
+ (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
}
const HooksDispatcherOnMount: Dispatcher = {
@@ -3021,6 +3053,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
+ (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
}
const HooksDispatcherOnUpdate: Dispatcher = {
@@ -3058,6 +3091,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
+ (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
}
const HooksDispatcherOnRerender: Dispatcher = {
@@ -3095,6 +3129,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
(HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
+ (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
}
let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
@@ -3287,6 +3322,16 @@ if (__DEV__) {
mountHookTypesDev();
return mountOptimistic(passthrough, reducer);
};
+ (HooksDispatcherOnMountInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ mountHookTypesDev();
+ return mountFormState(action, initialState, url);
+ };
}
HooksDispatcherOnMountWithHookTypesInDEV = {
@@ -3447,6 +3492,16 @@ if (__DEV__) {
updateHookTypesDev();
return mountOptimistic(passthrough, reducer);
};
+ (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ updateHookTypesDev();
+ return mountFormState(action, initialState, url);
+ };
}
HooksDispatcherOnUpdateInDEV = {
@@ -3609,6 +3664,16 @@ if (__DEV__) {
updateHookTypesDev();
return updateOptimistic(passthrough, reducer);
};
+ (HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ updateHookTypesDev();
+ return updateFormState(action, initialState, url);
+ };
}
HooksDispatcherOnRerenderInDEV = {
@@ -3771,6 +3836,16 @@ if (__DEV__) {
updateHookTypesDev();
return rerenderOptimistic(passthrough, reducer);
};
+ (HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ updateHookTypesDev();
+ return rerenderFormState(action, initialState, url);
+ };
}
InvalidNestedHooksDispatcherOnMountInDEV = {
@@ -3955,6 +4030,17 @@ if (__DEV__) {
mountHookTypesDev();
return mountOptimistic(passthrough, reducer);
};
+ (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ warnInvalidHookAccess();
+ mountHookTypesDev();
+ return mountFormState(action, initialState, url);
+ };
}
InvalidNestedHooksDispatcherOnUpdateInDEV = {
@@ -4142,6 +4228,17 @@ if (__DEV__) {
updateHookTypesDev();
return updateOptimistic(passthrough, reducer);
};
+ (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ warnInvalidHookAccess();
+ updateHookTypesDev();
+ return updateFormState(action, initialState, url);
+ };
}
InvalidNestedHooksDispatcherOnRerenderInDEV = {
@@ -4329,5 +4426,16 @@ if (__DEV__) {
updateHookTypesDev();
return rerenderOptimistic(passthrough, reducer);
};
+ (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
+ function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ): [S, (P) => void] {
+ currentHookNameInDev = 'useFormState';
+ warnInvalidHookAccess();
+ updateHookTypesDev();
+ return rerenderFormState(action, initialState, url);
+ };
}
}
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index 50a0ec85f9f25..2372fc13051ba 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -53,7 +53,8 @@ export type HookType =
| 'useSyncExternalStore'
| 'useId'
| 'useCacheRefresh'
- | 'useOptimistic';
+ | 'useOptimistic'
+ | 'useFormState';
export type ContextDependency = {
context: ReactContext,
@@ -413,6 +414,11 @@ export type Dispatcher = {
passthrough: S,
reducer: ?(S, A) => S,
) => [S, (A) => void],
+ useFormState?: (
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+ ) => [S, (P) => void],
};
export type CacheDispatcher = {
diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
index 65b14b87f569c..a8849b3768683 100644
--- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
@@ -6,6 +6,7 @@ let assertLog;
let useTransition;
let useState;
let useOptimistic;
+let useFormState;
let textCache;
describe('ReactAsyncActions', () => {
@@ -20,6 +21,7 @@ describe('ReactAsyncActions', () => {
useTransition = React.useTransition;
useState = React.useState;
useOptimistic = React.experimental_useOptimistic;
+ useFormState = React.experimental_useFormState;
textCache = new Map();
});
@@ -1074,4 +1076,23 @@ describe('ReactAsyncActions', () => {
>,
);
});
+
+ // @gate enableAsyncActions
+ test('useFormState exists', async () => {
+ // TODO: Not yet implemented. This just tests that the API is wired up.
+
+ async function action(state) {
+ return state;
+ }
+
+ function App() {
+ const [state] = useFormState(action, 0);
+ return ;
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => root.render());
+ assertLog([0]);
+ expect(root).toMatchRenderedOutput('0');
+ });
});
diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js
index f832aeb578fcb..dd56dceaf8c03 100644
--- a/packages/react-server/src/ReactFizzHooks.js
+++ b/packages/react-server/src/ReactFizzHooks.js
@@ -542,6 +542,10 @@ function unsupportedSetOptimisticState() {
throw new Error('Cannot update optimistic state while rendering.');
}
+function unsupportedDispatchFormState() {
+ throw new Error('Cannot update form state while rendering.');
+}
+
function useOptimistic(
passthrough: S,
reducer: ?(S, A) => S,
@@ -550,6 +554,15 @@ function useOptimistic(
return [passthrough, unsupportedSetOptimisticState];
}
+function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+): [S, (P) => void] {
+ resolveCurrentlyRenderingComponent();
+ return [initialState, unsupportedDispatchFormState];
+}
+
function useId(): string {
const task: Task = (currentlyRenderingTask: any);
const treeId = getTreeId(task.treeContext);
@@ -650,6 +663,7 @@ if (enableFormActions && enableAsyncActions) {
}
if (enableAsyncActions) {
HooksDispatcher.useOptimistic = useOptimistic;
+ HooksDispatcher.useFormState = useFormState;
}
export let currentResponseState: null | ResponseState = (null: any);
diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js
index 2d2c4ea8ff180..31c5990432876 100644
--- a/packages/react/index.classic.fb.js
+++ b/packages/react/index.classic.fb.js
@@ -53,6 +53,7 @@ export {
useInsertionEffect,
useMemo,
experimental_useOptimistic,
+ experimental_useFormState,
useReducer,
useRef,
useState,
diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js
index 081fa2746964b..7e07b970333ff 100644
--- a/packages/react/index.experimental.js
+++ b/packages/react/index.experimental.js
@@ -51,6 +51,7 @@ export {
useLayoutEffect,
useMemo,
experimental_useOptimistic,
+ experimental_useFormState,
useReducer,
useRef,
useState,
diff --git a/packages/react/index.js b/packages/react/index.js
index fd2c377668c21..5a59a730c4af4 100644
--- a/packages/react/index.js
+++ b/packages/react/index.js
@@ -76,6 +76,7 @@ export {
useLayoutEffect,
useMemo,
experimental_useOptimistic,
+ experimental_useFormState,
useSyncExternalStore,
useReducer,
useRef,
diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js
index 2658db8acfc82..3c7f15fcf2e2b 100644
--- a/packages/react/index.modern.fb.js
+++ b/packages/react/index.modern.fb.js
@@ -51,6 +51,7 @@ export {
useLayoutEffect,
useMemo,
experimental_useOptimistic,
+ experimental_useFormState,
useReducer,
useRef,
useState,
diff --git a/packages/react/src/React.js b/packages/react/src/React.js
index b45a0cda05370..e6a0a43751cfd 100644
--- a/packages/react/src/React.js
+++ b/packages/react/src/React.js
@@ -60,6 +60,7 @@ import {
use,
useMemoCache,
useOptimistic,
+ useFormState,
} from './ReactHooks';
import {
createElementWithValidation,
@@ -112,6 +113,7 @@ export {
useLayoutEffect,
useMemo,
useOptimistic as experimental_useOptimistic,
+ useFormState as experimental_useFormState,
useSyncExternalStore,
useReducer,
useRef,
diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js
index 112baab1eaa5f..22e9761b31000 100644
--- a/packages/react/src/ReactHooks.js
+++ b/packages/react/src/ReactHooks.js
@@ -237,3 +237,13 @@ export function useOptimistic(
// $FlowFixMe[not-a-function] This is unstable, thus optional
return dispatcher.useOptimistic(passthrough, reducer);
}
+
+export function useFormState(
+ action: (S, P) => S,
+ initialState: S,
+ url?: string,
+): [S, (P) => void] {
+ const dispatcher = resolveDispatcher();
+ // $FlowFixMe[not-a-function] This is unstable, thus optional
+ return dispatcher.useFormState(action, initialState, url);
+}