diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js
index 16184287b1228..70ccb05ca83ac 100644
--- a/fixtures/flight/server/global.js
+++ b/fixtures/flight/server/global.js
@@ -138,11 +138,15 @@ app.all('/', async function (req, res, next) {
       // For HTML, we're a "client" emulator that runs the client code,
       // so we start by consuming the RSC payload. This needs a module
       // map that reverse engineers the client-side path to the SSR path.
-      const root = await createFromNodeStream(rscResponse, moduleMap);
+      const {root, formState} = await createFromNodeStream(
+        rscResponse,
+        moduleMap
+      );
       // Render it into HTML by resolving the client components
       res.set('Content-type', 'text/html');
       const {pipe} = renderToPipeableStream(root, {
         bootstrapScripts: mainJSChunks,
+        experimental_formState: formState,
       });
       pipe(res);
     } catch (e) {
diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js
index 4beae03ff35ee..e89f113b7cad6 100644
--- a/fixtures/flight/server/region.js
+++ b/fixtures/flight/server/region.js
@@ -46,7 +46,7 @@ const {readFile} = require('fs').promises;
 
 const React = require('react');
 
-async function renderApp(res, returnValue) {
+async function renderApp(res, returnValue, formState) {
   const {renderToPipeableStream} = await import(
     'react-server-dom-webpack/server'
   );
@@ -93,13 +93,13 @@ async function renderApp(res, returnValue) {
     React.createElement(App),
   ];
   // For client-invoked server actions we refresh the tree and return a return value.
-  const payload = returnValue ? {returnValue, root} : root;
+  const payload = {root, returnValue, formState};
   const {pipe} = renderToPipeableStream(payload, moduleMap);
   pipe(res);
 }
 
 app.get('/', async function (req, res) {
-  await renderApp(res, null);
+  await renderApp(res, null, null);
 });
 
 app.post('/', bodyParser.text(), async function (req, res) {
@@ -108,6 +108,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
     decodeReply,
     decodeReplyFromBusboy,
     decodeAction,
+    decodeFormState,
   } = await import('react-server-dom-webpack/server');
   const serverReference = req.get('rsc-action');
   if (serverReference) {
@@ -139,7 +140,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
       // We handle the error on the client
     }
     // Refresh the client and return the value
-    renderApp(res, result);
+    renderApp(res, result, null);
   } else {
     // This is the progressive enhancement case
     const UndiciRequest = require('undici').Request;
@@ -153,12 +154,14 @@ app.post('/', bodyParser.text(), async function (req, res) {
     const action = await decodeAction(formData);
     try {
       // Wait for any mutations
-      await action();
+      const result = await action();
+      const formState = decodeFormState(result, formData);
+      renderApp(res, null, formState);
     } catch (x) {
       const {setServerState} = await import('../src/ServerState.js');
       setServerState('Error: ' + x.message);
+      renderApp(res, null, null);
     }
-    renderApp(res, null);
   }
 });
 
diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js
index 71eb4fa5d97a0..7a14beb460def 100644
--- a/fixtures/flight/src/App.js
+++ b/fixtures/flight/src/App.js
@@ -15,7 +15,7 @@ import {Client} from './Client.js';
 
 import {Note} from './cjs/Note.js';
 
-import {like, greet} from './actions.js';
+import {like, greet, increment} from './actions.js';
 
 import {getServerState} from './ServerState.js';
 
@@ -32,9 +32,9 @@ export default async function App() {
       
         
           {getServerState()}
-          
-          
-          
+          
+          
+          
           
             {todos.map(todo => (
               - {todo.text}
 
diff --git a/fixtures/flight/src/Counter.js b/fixtures/flight/src/Counter.js
index 8785424ca469f..5af74369a1eb8 100644
--- a/fixtures/flight/src/Counter.js
+++ b/fixtures/flight/src/Counter.js
@@ -1,14 +1,17 @@
 'use client';
 
 import * as React from 'react';
+import {experimental_useFormState as useFormState} from 'react-dom';
 
 import Container from './Container.js';
 
-export function Counter() {
-  const [count, setCount] = React.useState(0);
+export function Counter({incrementAction}) {
+  const [count, incrementFormAction] = useFormState(incrementAction, 0);
   return (
     
-      
+      
     
   );
 }
diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js
index 3d26189979c2f..aa19871a9dcbb 100644
--- a/fixtures/flight/src/actions.js
+++ b/fixtures/flight/src/actions.js
@@ -18,3 +18,7 @@ export async function greet(formData) {
   }
   return 'Hi ' + name + '!';
 }
+
+export async function increment(n) {
+  return n + 1;
+}
diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js
index d75feee56ec36..dd5a6b02b7681 100644
--- a/fixtures/flight/src/index.js
+++ b/fixtures/flight/src/index.js
@@ -24,21 +24,33 @@ async function callServer(id, args) {
   return returnValue;
 }
 
-let data = createFromFetch(
-  fetch('/', {
-    headers: {
-      Accept: 'text/x-component',
-    },
-  }),
-  {
-    callServer,
-  }
-);
-
 function Shell({data}) {
-  const [root, setRoot] = useState(use(data));
+  const [root, setRoot] = useState(data);
   updateRoot = setRoot;
   return root;
 }
 
-ReactDOM.hydrateRoot(document, );
+async function hydrateApp() {
+  const {root, returnValue, formState} = await createFromFetch(
+    fetch('/', {
+      headers: {
+        Accept: 'text/x-component',
+      },
+    }),
+    {
+      callServer,
+    }
+  );
+
+  ReactDOM.hydrateRoot(document, , {
+    // TODO: This part doesn't actually work because the server only returns
+    // form state during the request that submitted the form. Which means it
+    // the state needs to be transported as part of the HTML stream. We intend
+    // to add a feature to Fizz for this, but for now it's up to the
+    // metaframework to implement correctly.
+    experimental_formState: formState,
+  });
+}
+
+// Remove this line to simulate MPA behavior
+hydrateApp();
diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js
index bedf5a90496f0..96ebd193b162c 100644
--- a/packages/react-client/src/ReactFlightReplyClient.js
+++ b/packages/react-client/src/ReactFlightReplyClient.js
@@ -7,7 +7,12 @@
  * @flow
  */
 
-import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
+import type {
+  Thenable,
+  FulfilledThenable,
+  RejectedThenable,
+  ReactCustomFormAction,
+} from 'shared/ReactTypes';
 
 import {
   REACT_ELEMENT_TYPE,
@@ -23,10 +28,6 @@ import {
 } from 'shared/ReactSerializationErrors';
 
 import isArray from 'shared/isArray';
-import type {
-  FulfilledThenable,
-  RejectedThenable,
-} from '../../shared/ReactTypes';
 
 import {usedWithSSR} from './ReactFlightClientConfig';
 
diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js
index b8b1fc43239a9..ce0c9ff609ebd 100644
--- a/packages/react-dom/src/client/ReactDOMLegacy.js
+++ b/packages/react-dom/src/client/ReactDOMLegacy.js
@@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer(
       noopOnRecoverableError,
       // TODO(luna) Support hydration later
       null,
+      null,
     );
     container._reactRootContainer = root;
     markContainerAsRoot(root.current, container);
diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js
index b55b09eb2124d..55bbc6627922c 100644
--- a/packages/react-dom/src/client/ReactDOMRoot.js
+++ b/packages/react-dom/src/client/ReactDOMRoot.js
@@ -7,7 +7,7 @@
  * @flow
  */
 
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {
   FiberRoot,
   TransitionTracingCallbacks,
@@ -21,6 +21,8 @@ import {
   enableHostSingletons,
   allowConcurrentByDefault,
   disableCommentsAsDOMContainers,
+  enableAsyncActions,
+  enableFormActions,
 } from 'shared/ReactFeatureFlags';
 
 import ReactDOMSharedInternals from '../ReactDOMSharedInternals';
@@ -55,6 +57,7 @@ export type HydrateRootOptions = {
   unstable_transitionCallbacks?: TransitionTracingCallbacks,
   identifierPrefix?: string,
   onRecoverableError?: (error: mixed) => void,
+  experimental_formState?: ReactFormState | null,
   ...
 };
 
@@ -302,6 +305,7 @@ export function hydrateRoot(
   let identifierPrefix = '';
   let onRecoverableError = defaultOnRecoverableError;
   let transitionCallbacks = null;
+  let formState = null;
   if (options !== null && options !== undefined) {
     if (options.unstable_strictMode === true) {
       isStrictMode = true;
@@ -321,6 +325,11 @@ export function hydrateRoot(
     if (options.unstable_transitionCallbacks !== undefined) {
       transitionCallbacks = options.unstable_transitionCallbacks;
     }
+    if (enableAsyncActions && enableFormActions) {
+      if (options.experimental_formState !== undefined) {
+        formState = options.experimental_formState;
+      }
+    }
   }
 
   const root = createHydrationContainer(
@@ -334,6 +343,7 @@ export function hydrateRoot(
     identifierPrefix,
     onRecoverableError,
     transitionCallbacks,
+    formState,
   );
   markContainerAsRoot(root.current, container);
   Dispatcher.current = ReactDOMClientDispatcher;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
index 44df0fb2deecc..0cd08fcdb33a9 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
@@ -8,7 +8,7 @@
  */
 
 import type {PostponedState} from 'react-server/src/ReactFizzServer';
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
 import type {ImportMap} from '../shared/ReactDOMTypes';
 
@@ -41,6 +41,7 @@ type Options = {
   onPostpone?: (reason: string) => void,
   unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
   importMap?: ImportMap,
+  experimental_formState?: ReactFormState | null,
 };
 
 type ResumeOptions = {
@@ -117,6 +118,7 @@ function renderToReadableStream(
       onShellError,
       onFatalError,
       options ? options.onPostpone : undefined,
+      options ? options.experimental_formState : undefined,
     );
     if (options && options.signal) {
       const signal = options.signal;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js
index 997934e1a3d1a..3de5fa51e51aa 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js
@@ -7,7 +7,7 @@
  * @flow
  */
 
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
 import type {ImportMap} from '../shared/ReactDOMTypes';
 
@@ -39,6 +39,7 @@ type Options = {
   onPostpone?: (reason: string) => void,
   unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
   importMap?: ImportMap,
+  experimental_formState?: ReactFormState | null,
 };
 
 // TODO: Move to sub-classing ReadableStream.
@@ -108,6 +109,7 @@ function renderToReadableStream(
       onShellError,
       onFatalError,
       options ? options.onPostpone : undefined,
+      options ? options.experimental_formState : undefined,
     );
     if (options && options.signal) {
       const signal = options.signal;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js
index 44df0fb2deecc..0cd08fcdb33a9 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js
@@ -8,7 +8,7 @@
  */
 
 import type {PostponedState} from 'react-server/src/ReactFizzServer';
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
 import type {ImportMap} from '../shared/ReactDOMTypes';
 
@@ -41,6 +41,7 @@ type Options = {
   onPostpone?: (reason: string) => void,
   unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
   importMap?: ImportMap,
+  experimental_formState?: ReactFormState | null,
 };
 
 type ResumeOptions = {
@@ -117,6 +118,7 @@ function renderToReadableStream(
       onShellError,
       onFatalError,
       options ? options.onPostpone : undefined,
+      options ? options.experimental_formState : undefined,
     );
     if (options && options.signal) {
       const signal = options.signal;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
index c7de8e073e16e..af1022dc013b1 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
@@ -8,7 +8,7 @@
  */
 
 import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {Writable} from 'stream';
 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
 import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
@@ -54,6 +54,7 @@ type Options = {
   onPostpone?: (reason: string) => void,
   unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
   importMap?: ImportMap,
+  experimental_formState?: ReactFormState | null,
 };
 
 type ResumeOptions = {
@@ -97,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
     options ? options.onShellError : undefined,
     undefined,
     options ? options.onPostpone : undefined,
+    options ? options.experimental_formState : undefined,
   );
 }
 
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index f8f935846f86f..1f8438b035edd 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -2010,28 +2010,36 @@ function formStateReducer(oldState: S, newState: S): S {
 
 function mountFormState(
   action: (S, P) => Promise,
-  initialState: S,
+  initialStateProp: S,
   permalink?: string,
 ): [S, (P) => void] {
+  let initialState = initialStateProp;
   if (getIsHydrating()) {
-    // TODO: If this function returns true, it means we should use the form
-    // state passed to hydrateRoot instead of initialState.
-    tryToClaimNextHydratableFormMarkerInstance(currentlyRenderingFiber);
+    const isMatching = tryToClaimNextHydratableFormMarkerInstance(
+      currentlyRenderingFiber,
+    );
+    const root: FiberRoot = (getWorkInProgressRoot(): any);
+    const ssrFormState = root.formState;
+    if (ssrFormState !== null && isMatching) {
+      initialState = ssrFormState[0];
+    }
   }
+  const initialStateThenable: Thenable = {
+    status: 'fulfilled',
+    value: initialState,
+    then() {},
+  };
 
   // State hook. The state is stored in a thenable which is then unwrapped by
   // the `use` algorithm during render.
   const stateHook = mountWorkInProgressHook();
-  stateHook.memoizedState = stateHook.baseState = {
-    status: 'fulfilled',
-    value: initialState,
-  };
+  stateHook.memoizedState = stateHook.baseState = initialStateThenable;
   const stateQueue: UpdateQueue, Thenable> = {
     pending: null,
     lanes: NoLanes,
     dispatch: null,
     lastRenderedReducer: formStateReducer,
-    lastRenderedState: (initialState: any),
+    lastRenderedState: initialStateThenable,
   };
   stateHook.queue = stateQueue;
   const setState: Dispatch> = (dispatchSetState.bind(
diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js
index 4b5193585a0b0..599fa854c0a79 100644
--- a/packages/react-reconciler/src/ReactFiberReconciler.js
+++ b/packages/react-reconciler/src/ReactFiberReconciler.js
@@ -21,7 +21,7 @@ import type {
   PublicInstance,
   RendererInspectionConfig,
 } from './ReactFiberConfig';
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {Lane} from './ReactFiberLane';
 import type {SuspenseState} from './ReactFiberSuspenseComponent';
 
@@ -265,6 +265,7 @@ export function createContainer(
     identifierPrefix,
     onRecoverableError,
     transitionCallbacks,
+    null,
   );
 }
 
@@ -280,6 +281,7 @@ export function createHydrationContainer(
   identifierPrefix: string,
   onRecoverableError: (error: mixed) => void,
   transitionCallbacks: null | TransitionTracingCallbacks,
+  formState: ReactFormState | null,
 ): OpaqueRoot {
   const hydrate = true;
   const root = createFiberRoot(
@@ -293,6 +295,7 @@ export function createHydrationContainer(
     identifierPrefix,
     onRecoverableError,
     transitionCallbacks,
+    formState,
   );
 
   // TODO: Move this to FiberRoot constructor
diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js
index e65e25b97df6b..c686dba7c7e87 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.js
@@ -7,7 +7,7 @@
  * @flow
  */
 
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
 import type {
   FiberRoot,
   SuspenseHydrationCallbacks,
@@ -52,6 +52,7 @@ function FiberRootNode(
   hydrate: any,
   identifierPrefix: any,
   onRecoverableError: any,
+  formState: ReactFormState | null,
 ) {
   this.tag = tag;
   this.containerInfo = containerInfo;
@@ -93,6 +94,8 @@ function FiberRootNode(
     this.hydrationCallbacks = null;
   }
 
+  this.formState = formState;
+
   this.incompleteTransitions = new Map();
   if (enableTransitionTracing) {
     this.transitionCallbacks = null;
@@ -142,6 +145,7 @@ export function createFiberRoot(
   identifierPrefix: string,
   onRecoverableError: null | ((error: mixed) => void),
   transitionCallbacks: null | TransitionTracingCallbacks,
+  formState: ReactFormState | null,
 ): FiberRoot {
   // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
   const root: FiberRoot = (new FiberRootNode(
@@ -150,6 +154,7 @@ export function createFiberRoot(
     hydrate,
     identifierPrefix,
     onRecoverableError,
+    formState,
   ): any);
   if (enableSuspenseCallback) {
     root.hydrationCallbacks = hydrationCallbacks;
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index 8dae4fa10e07f..e6b002d0e357b 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -14,6 +14,7 @@ import type {
   StartTransitionOptions,
   Wakeable,
   Usable,
+  ReactFormState,
 } from 'shared/ReactTypes';
 import type {WorkTag} from './ReactWorkTags';
 import type {TypeOfMode} from './ReactTypeOfMode';
@@ -270,6 +271,8 @@ type BaseFiberRootProperties = {
     error: mixed,
     errorInfo: {digest?: ?string, componentStack?: ?string},
   ) => void,
+
+  formState: ReactFormState | null,
 };
 
 // The following attributes are only used by DevTools and are only present in DEV builds.
diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
index def3a58478e57..289c79b3dfa10 100644
--- a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+++ b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
@@ -36,7 +36,10 @@ import {
   getRoot,
 } from 'react-server/src/ReactFlightReplyServer';
 
-import {decodeAction} from 'react-server/src/ReactFlightActionServer';
+import {
+  decodeAction,
+  decodeFormState,
+} from 'react-server/src/ReactFlightActionServer';
 
 export {
   registerServerReference,
@@ -166,4 +169,5 @@ export {
   decodeReplyFromBusboy,
   decodeReply,
   decodeAction,
+  decodeFormState,
 };
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
index 08214a4182ab2..f2904f8a84e68 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
@@ -25,7 +25,10 @@ import {
   getRoot,
 } from 'react-server/src/ReactFlightReplyServer';
 
-import {decodeAction} from 'react-server/src/ReactFlightActionServer';
+import {
+  decodeAction,
+  decodeFormState,
+} from 'react-server/src/ReactFlightActionServer';
 
 export {
   registerServerReference,
@@ -97,4 +100,4 @@ function decodeReply(
   return getRoot(response);
 }
 
-export {renderToReadableStream, decodeReply, decodeAction};
+export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
index 08214a4182ab2..f2904f8a84e68 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
@@ -25,7 +25,10 @@ import {
   getRoot,
 } from 'react-server/src/ReactFlightReplyServer';
 
-import {decodeAction} from 'react-server/src/ReactFlightActionServer';
+import {
+  decodeAction,
+  decodeFormState,
+} from 'react-server/src/ReactFlightActionServer';
 
 export {
   registerServerReference,
@@ -97,4 +100,4 @@ function decodeReply(
   return getRoot(response);
 }
 
-export {renderToReadableStream, decodeReply, decodeAction};
+export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
index 1e39d000ffef4..4818582ecf28e 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
@@ -36,7 +36,10 @@ import {
   getRoot,
 } from 'react-server/src/ReactFlightReplyServer';
 
-import {decodeAction} from 'react-server/src/ReactFlightActionServer';
+import {
+  decodeAction,
+  decodeFormState,
+} from 'react-server/src/ReactFlightActionServer';
 
 export {
   registerServerReference,
@@ -167,4 +170,5 @@ export {
   decodeReplyFromBusboy,
   decodeReply,
   decodeAction,
+  decodeFormState,
 };
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 2d55e05c7d142..4249055bbef9f 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
@@ -30,7 +30,9 @@ let React;
 let ReactDOMServer;
 let ReactServerDOMServer;
 let ReactServerDOMClient;
+let ReactDOMClient;
 let useFormState;
+let act;
 
 describe('ReactFlightDOMForm', () => {
   beforeEach(() => {
@@ -48,6 +50,8 @@ describe('ReactFlightDOMForm', () => {
     ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
     ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
     ReactDOMServer = require('react-dom/server.edge');
+    ReactDOMClient = require('react-dom/client');
+    act = require('react-dom/test-utils').act;
     useFormState = require('react-dom').experimental_useFormState;
     container = document.createElement('div');
     document.body.appendChild(container);
@@ -62,7 +66,13 @@ describe('ReactFlightDOMForm', () => {
       formData,
       webpackServerMap,
     );
-    return boundAction();
+    const returnValue = boundAction();
+    const formState = ReactServerDOMServer.decodeFormState(
+      await returnValue,
+      formData,
+      webpackServerMap,
+    );
+    return {returnValue, formState};
   }
 
   function submit(submitter) {
@@ -138,9 +148,9 @@ describe('ReactFlightDOMForm', () => {
 
     expect(foo).toBe(null);
 
-    const result = await submit(form);
+    const {returnValue} = await submit(form);
 
-    expect(result).toBe('hello');
+    expect(returnValue).toBe('hello');
     expect(foo).toBe('bar');
   });
 
@@ -170,9 +180,9 @@ describe('ReactFlightDOMForm', () => {
 
     expect(foo).toBe(null);
 
-    const result = await submit(form);
+    const {returnValue} = await submit(form);
 
-    expect(result).toBe('hi');
+    expect(returnValue).toBe('hi');
 
     expect(foo).toBe('bar');
   });
@@ -201,9 +211,9 @@ describe('ReactFlightDOMForm', () => {
 
     expect(foo).toBe(null);
 
-    const result = await submit(form);
+    const {returnValue} = await submit(form);
 
-    expect(result).toBe('hello');
+    expect(returnValue).toBe('hello');
     expect(foo).toBe('barobject');
   });
 
@@ -237,9 +247,9 @@ describe('ReactFlightDOMForm', () => {
 
     expect(foo).toBe(null);
 
-    const result = await submit(form.getElementsByTagName('button')[1]);
+    const {returnValue} = await submit(form.getElementsByTagName('button')[1]);
 
-    expect(result).toBe('helloc');
+    expect(returnValue).toBe('helloc');
     expect(foo).toBe('barc');
   });
 
@@ -269,9 +279,9 @@ describe('ReactFlightDOMForm', () => {
 
     expect(foo).toBe(null);
 
-    const result = await submit(form);
+    const {returnValue} = await submit(form);
 
-    expect(result).toBe('hello');
+    expect(returnValue).toBe('hello');
     expect(foo).toBe('barobject');
   });
 
@@ -305,23 +315,22 @@ describe('ReactFlightDOMForm', () => {
 
     expect(foo).toBe(null);
 
-    const result = await submit(form);
+    const {returnValue} = await submit(form);
 
-    expect(result).toBe('hello');
+    expect(returnValue).toBe('hello');
     expect(foo).toBe('barobject');
   });
 
   // @gate enableFormActions
   // @gate enableAsyncActions
   it("useFormState's dispatch binds the initial state to the provided action", async () => {
-    let serverActionResult = null;
-
-    const serverAction = serverExports(function action(prevState, formData) {
-      const newState = {
+    const serverAction = serverExports(async function action(
+      prevState,
+      formData,
+    ) {
+      return {
         count: prevState.count + parseInt(formData.get('incrementAmount'), 10),
       };
-      serverActionResult = newState;
-      return newState;
     });
 
     const initialState = {count: 1};
@@ -348,8 +357,82 @@ describe('ReactFlightDOMForm', () => {
     const span = container.getElementsByTagName('span')[0];
     expect(span.textContent).toBe('Count: 1');
 
-    await submit(form);
-    expect(serverActionResult.count).toBe(6);
+    const {returnValue} = await submit(form);
+    expect(await returnValue).toEqual({count: 6});
+  });
+
+  // @gate enableFormActions
+  // @gate enableAsyncActions
+  it('useFormState can reuse state during MPA form submission', async () => {
+    const serverAction = serverExports(async function action(
+      prevState,
+      formData,
+    ) {
+      return prevState + 1;
+    });
+
+    function Form({action}) {
+      const [count, dispatch] = useFormState(action, 1);
+      return ;
+    }
+
+    function Client({action}) {
+      return (
+        
+          
+          
+          
+        
+      );
+    }
+
+    const ClientRef = await clientExports(Client);
+
+    const rscStream = ReactServerDOMServer.renderToReadableStream(
+      ,
+      webpackMap,
+    );
+    const response = ReactServerDOMClient.createFromReadableStream(rscStream);
+    const ssrStream = await ReactDOMServer.renderToReadableStream(response);
+    await readIntoContainer(ssrStream);
+
+    expect(container.textContent).toBe('111');
+
+    // There are three identical forms. We're going to submit the second one.
+    const form = container.getElementsByTagName('form')[1];
+    const {formState} = await submit(form);
+
+    // Simulate an MPA form submission by resetting the container and
+    // rendering again.
+    container.innerHTML = '';
+
+    const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
+      ,
+      webpackMap,
+    );
+    const postbackResponse =
+      ReactServerDOMClient.createFromReadableStream(postbackRscStream);
+    const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
+      postbackResponse,
+      {experimental_formState: formState},
+    );
+    await readIntoContainer(postbackSsrStream);
+
+    // Only the second form's state should have been updated.
+    expect(container.textContent).toBe('121');
+
+    // Test that it hydrates correctly
+    if (__DEV__) {
+      // TODO: Can't use our internal act() util that works in production
+      // because it works by overriding the timer APIs, which this test module
+      // also does. Remove dev condition once FlightServer.act() is available.
+      await act(() => {
+        ReactDOMClient.hydrateRoot(container, postbackResponse, {
+          experimental_formState: formState,
+        });
+      });
+      expect(container.textContent).toBe('121');
+    }
   });
 
   // @gate enableFormActions
diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js
index e18fa1509acbf..533c9658a8a38 100644
--- a/packages/react-server/src/ReactFizzHooks.js
+++ b/packages/react-server/src/ReactFizzHooks.js
@@ -18,7 +18,7 @@ import type {
 } from 'shared/ReactTypes';
 
 import type {ResumableState} from './ReactFizzConfig';
-import type {Task} from './ReactFizzServer';
+import type {Request, Task, KeyNode} from './ReactFizzServer';
 import type {ThenableState} from './ReactFizzThenable';
 import type {TransitionStatus} from './ReactFizzConfig';
 
@@ -42,6 +42,7 @@ import {
   REACT_MEMO_CACHE_SENTINEL,
 } from 'shared/ReactSymbols';
 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
+import {getFormState} from './ReactFizzServer';
 
 type BasicStateAction = (S => S) | S;
 type Dispatch = A => void;
@@ -64,6 +65,8 @@ type Hook = {
 
 let currentlyRenderingComponent: Object | null = null;
 let currentlyRenderingTask: Task | null = null;
+let currentlyRenderingRequest: Request | null = null;
+let currentlyRenderingKeyPath: KeyNode | null = null;
 let firstWorkInProgressHook: Hook | null = null;
 let workInProgressHook: Hook | null = null;
 // Whether the work-in-progress hook is a re-rendered hook
@@ -197,12 +200,16 @@ function createWorkInProgressHook(): Hook {
 }
 
 export function prepareToUseHooks(
+  request: Request,
   task: Task,
+  keyPath: KeyNode | null,
   componentIdentity: Object,
   prevThenableState: ThenableState | null,
 ): void {
   currentlyRenderingComponent = componentIdentity;
   currentlyRenderingTask = task;
+  currentlyRenderingRequest = request;
+  currentlyRenderingKeyPath = keyPath;
   if (__DEV__) {
     isInHookUserCodeInDev = false;
   }
@@ -287,6 +294,8 @@ export function resetHooksState(): void {
 
   currentlyRenderingComponent = null;
   currentlyRenderingTask = null;
+  currentlyRenderingRequest = null;
+  currentlyRenderingKeyPath = null;
   didScheduleRenderPhaseUpdate = false;
   firstWorkInProgressHook = null;
   numberOfReRenders = 0;
@@ -584,15 +593,43 @@ function useFormState(
 ): [S, (P) => void] {
   resolveCurrentlyRenderingComponent();
 
-  // Count the number of useFormState hooks per component.
-  // TODO: We should also track which hook matches the form state passed at
-  // the root, if any. Matching is not yet implemented.
-  formStateCounter++;
+  // Count the number of useFormState hooks per component. We also use this to
+  // track the position of this useFormState hook relative to the other ones in
+  // this component, so we can generate a unique key for each one.
+  const formStateHookIndex = formStateCounter++;
+  const request: Request = (currentlyRenderingRequest: any);
+
+  // Append a node to the key path that represents the form state hook.
+  const componentKey: KeyNode | null = (currentlyRenderingKeyPath: any);
+  const key: KeyNode = [componentKey, null, formStateHookIndex];
+  const keyJSON = JSON.stringify(key);
+
+  // Get the form state. If we received form state from a previous page, then
+  // we should reuse that, if the action identity matches. Otherwise we'll use
+  // the initial state argument. We emit a comment marker into the stream
+  // that indicates whether the state was reused.
+  let state;
+  const postbackFormState = getFormState(request);
+  if (postbackFormState !== null) {
+    const postbackKey = postbackFormState[1];
+    // TODO: Compare the action identity, too
+    // TODO: If a permalink is used, disregard the key and compare that instead.
+    if (keyJSON === postbackKey) {
+      // This was a match.
+      formStateMatchingIndex = formStateHookIndex;
+      // Reuse the state that was submitted by the form.
+      state = postbackFormState[0];
+    } else {
+      state = initialState;
+    }
+  } else {
+    // TODO: As an optimization, Fizz should only emit these markers if form
+    // state is passed at the root.
+    state = initialState;
+  }
 
-  // Bind the initial state to the first argument of the action.
-  // TODO: Use the keypath (or permalink) to check if there's matching state
-  // from the previous page.
-  const boundAction = action.bind(null, initialState);
+  // Bind the state to the first argument of the action.
+  const boundAction = action.bind(null, state);
 
   // Wrap the action so the return value is void.
   const dispatch = (payload: P): void => {
@@ -605,6 +642,12 @@ function useFormState(
     dispatch.$$FORM_ACTION = (prefix: string) => {
       // $FlowIgnore[prop-missing]
       const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
+
+      const formData = metadata.data;
+      if (formData) {
+        formData.append('$ACTION_KEY', keyJSON);
+      }
+
       // Override the action URL
       if (permalink !== undefined) {
         if (__DEV__) {
@@ -619,7 +662,7 @@ function useFormState(
     // no effect. The form will have to be hydrated before it's submitted.
   }
 
-  return [initialState, dispatch];
+  return [state, dispatch];
 }
 
 function useId(): string {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 4d035062bfa25..05debbfa0f2a6 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -19,6 +19,7 @@ import type {
   OffscreenMode,
   Wakeable,
   Thenable,
+  ReactFormState,
 } from 'shared/ReactTypes';
 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
 import type {
@@ -158,7 +159,7 @@ const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
 // Linked list representing the identity of a component given the component/tag name and key.
 // The name might be minified but we assume that it's going to be the same generated name. Typically
 // because it's just the same compiled output in practice.
-type KeyNode = [
+export type KeyNode = [
   Root | KeyNode /* parent */,
   string | null /* name */,
   string | number /* key */,
@@ -311,6 +312,8 @@ export opaque type Request = {
   // onPostpone is called when postpone() is called anywhere in the tree, which will defer
   // rendering - e.g. to the client. This is considered intentional and not an error.
   onPostpone: (reason: string) => void,
+  // Form state that was the result of an MPA submission, if it was provided.
+  formState: null | ReactFormState,
 };
 
 // This is a default heuristic for how to split up the HTML content into progressive
@@ -349,6 +352,7 @@ export function createRequest(
   onShellError: void | ((error: mixed) => void),
   onFatalError: void | ((error: mixed) => void),
   onPostpone: void | ((reason: string) => void),
+  formState: void | null | ReactFormState,
 ): Request {
   prepareHostDispatcher();
   const pingedTasks: Array = [];
@@ -381,6 +385,7 @@ export function createRequest(
     onShellReady: onShellReady === undefined ? noop : onShellReady,
     onShellError: onShellError === undefined ? noop : onShellError,
     onFatalError: onFatalError === undefined ? noop : onFatalError,
+    formState: formState === undefined ? null : formState,
   };
   // This segment represents the root fallback.
   const rootSegment = createPendingSegment(
@@ -482,6 +487,7 @@ export function resumeRequest(
     onShellReady: onShellReady === undefined ? noop : onShellReady,
     onShellError: onShellError === undefined ? noop : onShellError,
     onFatalError: onFatalError === undefined ? noop : onFatalError,
+    formState: null,
   };
   // This segment represents the root fallback.
   const rootSegment = createPendingSegment(
@@ -956,13 +962,20 @@ function shouldConstruct(Component: any) {
 function renderWithHooks(
   request: Request,
   task: Task,
+  keyPath: Root | KeyNode,
   prevThenableState: ThenableState | null,
   Component: (p: Props, arg: SecondArg) => any,
   props: Props,
   secondArg: SecondArg,
 ): any {
   const componentIdentity = {};
-  prepareToUseHooks(task, componentIdentity, prevThenableState);
+  prepareToUseHooks(
+    request,
+    task,
+    keyPath,
+    componentIdentity,
+    prevThenableState,
+  );
   const result = Component(props, secondArg);
   return finishHooks(Component, props, result, secondArg);
 }
@@ -1078,6 +1091,7 @@ function renderIndeterminateComponent(
   const value = renderWithHooks(
     request,
     task,
+    keyPath,
     prevThenableState,
     Component,
     props,
@@ -1309,6 +1323,7 @@ function renderForwardRef(
   const children = renderWithHooks(
     request,
     task,
+    keyPath,
     prevThenableState,
     type.render,
     props,
@@ -3080,6 +3095,10 @@ export function flushResources(request: Request): void {
   enqueueFlush(request);
 }
 
+export function getFormState(request: Request): ReactFormState | null {
+  return request.formState;
+}
+
 export function getResumableState(request: Request): ResumableState {
   return request.resumableState;
 }
diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js
index ee735c2e8f9a2..19a944df714cb 100644
--- a/packages/react-server/src/ReactFlightActionServer.js
+++ b/packages/react-server/src/ReactFlightActionServer.js
@@ -7,7 +7,7 @@
  * @flow
  */
 
-import type {Thenable} from 'shared/ReactTypes';
+import type {Thenable, ReactFormState} from 'shared/ReactTypes';
 
 import type {
   ServerManifest,
@@ -108,3 +108,18 @@ export function decodeAction(
   // Return the action with the remaining FormData bound to the first argument.
   return action.then(fn => fn.bind(null, formData));
 }
+
+// TODO: Should this be an async function to preserve the option in the future
+// to do async stuff in here? Would also make it consistent with decodeAction
+export function decodeFormState(
+  actionResult: S,
+  body: FormData,
+  serverManifest: ServerManifest,
+): ReactFormState | null {
+  const keyPath = body.get('$ACTION_KEY');
+  if (typeof keyPath !== 'string') {
+    // This form submission did not include any form state.
+    return null;
+  }
+  return [actionResult, keyPath];
+}
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index 625d687e8829f..a6d2298df9eb1 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -174,3 +174,11 @@ export type ReactCustomFormAction = {
   target?: string,
   data?: null | FormData,
 };
+
+// This is an opaque type returned by decodeFormState on the server, but it's
+// defined in this shared file because the same type is used by React on
+// the client.
+export type ReactFormState = [
+  S /* actual state value */,
+  string /* key path */,
+];