Skip to content

Commit f1aa04b

Browse files
acdliteAndyPengc12
authored andcommitted
Scaffolding for useFormState (facebook#27270)
This exposes, but does not yet implement, a new experimental API called useFormState. It's gated behind the enableAsyncActions flag. useFormState has a similar signature to useReducer, except instead of a reducer it accepts an (async) action function. React will wait until the promise resolves before updating the state: ```js async function action(prevState, payload) { // .. } const [state, dispatch] = useFormState(action, initialState) ``` When used in combination with Server Actions, it will also support progressive enhancement — a form that is submitted before it has hydrated will have its state transferred to the next page. However, like the other action-related hooks, it works with fully client-driven actions, too.
1 parent b9e170c commit f1aa04b

15 files changed

+205
-4
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ module.exports = {
456456
$ReadOnlyArray: 'readonly',
457457
$ArrayBufferView: 'readonly',
458458
$Shape: 'readonly',
459+
ReturnType: 'readonly',
459460
AnimationFrameID: 'readonly',
460461
// For Flow type annotation. Only `BigInt` is valid at runtime.
461462
bigint: 'readonly',

packages/react-dom-bindings/src/shared/ReactDOMFormActions.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,17 @@ export function useFormStatus(): FormStatus {
7474
return dispatcher.useHostTransitionStatus();
7575
}
7676
}
77+
78+
export function useFormState<S, P>(
79+
action: (S, P) => S,
80+
initialState: S,
81+
url?: string,
82+
): [S, (P) => void] {
83+
if (!(enableFormActions && enableAsyncActions)) {
84+
throw new Error('Not implemented.');
85+
} else {
86+
const dispatcher = resolveDispatcher();
87+
// $FlowFixMe[not-a-function] This is unstable, thus optional
88+
return dispatcher.useFormState(action, initialState, url);
89+
}
90+
}

packages/react-dom/index.classic.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export {
3232
unstable_renderSubtreeIntoContainer,
3333
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
3434
useFormStatus as experimental_useFormStatus,
35+
useFormState as experimental_useFormState,
3536
prefetchDNS,
3637
preconnect,
3738
preload,

packages/react-dom/index.experimental.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
unstable_renderSubtreeIntoContainer,
2222
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
2323
useFormStatus as experimental_useFormStatus,
24+
useFormState as experimental_useFormState,
2425
prefetchDNS,
2526
preconnect,
2627
preload,

packages/react-dom/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export {
2424
unstable_renderSubtreeIntoContainer,
2525
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
2626
useFormStatus as experimental_useFormStatus,
27+
useFormState as experimental_useFormState,
2728
prefetchDNS,
2829
preconnect,
2930
preload,

packages/react-dom/index.modern.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
unstable_createEventHandle,
1818
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
1919
useFormStatus as experimental_useFormStatus,
20+
useFormState as experimental_useFormState,
2021
prefetchDNS,
2122
preconnect,
2223
preload,

packages/react-dom/server-rendering-stub.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ export {
2323
preload,
2424
preinit,
2525
experimental_useFormStatus,
26+
experimental_useFormState,
2627
unstable_batchedUpdates,
2728
} from './src/server/ReactDOMServerRenderingStub';

packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ let ReactDOMServer;
2323
let ReactDOMClient;
2424
let useFormStatus;
2525
let useOptimistic;
26+
let useFormState;
2627

2728
describe('ReactDOMFizzForm', () => {
2829
beforeEach(() => {
@@ -31,6 +32,7 @@ describe('ReactDOMFizzForm', () => {
3132
ReactDOMServer = require('react-dom/server.browser');
3233
ReactDOMClient = require('react-dom/client');
3334
useFormStatus = require('react-dom').experimental_useFormStatus;
35+
useFormState = require('react-dom').experimental_useFormState;
3436
useOptimistic = require('react').experimental_useOptimistic;
3537
act = require('internal-test-utils').act;
3638
container = document.createElement('div');
@@ -470,6 +472,28 @@ describe('ReactDOMFizzForm', () => {
470472
expect(container.textContent).toBe('hi');
471473
});
472474

475+
// @gate enableFormActions
476+
// @gate enableAsyncActions
477+
it('useFormState returns initial state', async () => {
478+
async function action(state) {
479+
return state;
480+
}
481+
482+
function App() {
483+
const [state] = useFormState(action, 0);
484+
return state;
485+
}
486+
487+
const stream = await ReactDOMServer.renderToReadableStream(<App />);
488+
await readIntoContainer(stream);
489+
expect(container.textContent).toBe('0');
490+
491+
await act(async () => {
492+
ReactDOMClient.hydrateRoot(container, <App />);
493+
});
494+
expect(container.textContent).toBe('0');
495+
});
496+
473497
// @gate enableFormActions
474498
it('can provide a custom action on the server for actions', async () => {
475499
const ref = React.createRef();

packages/react-dom/src/__tests__/ReactDOMForm-test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('ReactDOMForm', () => {
4040
let startTransition;
4141
let textCache;
4242
let useFormStatus;
43+
let useFormState;
4344

4445
beforeEach(() => {
4546
jest.resetModules();
@@ -53,6 +54,7 @@ describe('ReactDOMForm', () => {
5354
Suspense = React.Suspense;
5455
startTransition = React.startTransition;
5556
useFormStatus = ReactDOM.experimental_useFormStatus;
57+
useFormState = ReactDOM.experimental_useFormState;
5658
container = document.createElement('div');
5759
document.body.appendChild(container);
5860

@@ -969,4 +971,24 @@ describe('ReactDOMForm', () => {
969971
'A React form was unexpectedly submitted. If you called form.submit()',
970972
);
971973
});
974+
975+
// @gate enableFormActions
976+
// @gate enableAsyncActions
977+
test('useFormState exists', async () => {
978+
// TODO: Not yet implemented. This just tests that the API is wired up.
979+
980+
async function action(state) {
981+
return state;
982+
}
983+
984+
function App() {
985+
const [state] = useFormState(action, 0);
986+
return <Text text={state} />;
987+
}
988+
989+
const root = ReactDOMClient.createRoot(container);
990+
await act(() => root.render(<App />));
991+
assertLog([0]);
992+
expect(container.textContent).toBe('0');
993+
});
972994
});

packages/react-dom/src/client/ReactDOM.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ export {
6363
preinit,
6464
preinitModule,
6565
} from '../shared/ReactDOMFloat';
66-
export {useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
66+
export {
67+
useFormStatus,
68+
useFormState,
69+
} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
6770

6871
if (__DEV__) {
6972
if (

packages/react-dom/src/server/ReactDOMServerRenderingStub.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export {
1313
preconnect,
1414
prefetchDNS,
1515
} from '../shared/ReactDOMFloat';
16-
export {useFormStatus as experimental_useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
16+
export {
17+
useFormStatus as experimental_useFormStatus,
18+
useFormState as experimental_useFormState,
19+
} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
1720

1821
export function createPortal() {
1922
throw new Error(

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,37 @@ function rerenderOptimistic<S, A>(
18351835
return [passthrough, dispatch];
18361836
}
18371837

1838+
function TODO_formStateDispatch() {
1839+
throw new Error('Not implemented.');
1840+
}
1841+
1842+
function mountFormState<S, P>(
1843+
action: (S, P) => S,
1844+
initialState: S,
1845+
url?: string,
1846+
): [S, (P) => void] {
1847+
// TODO: Not yet implemented
1848+
return [initialState, TODO_formStateDispatch];
1849+
}
1850+
1851+
function updateFormState<S, P>(
1852+
action: (S, P) => S,
1853+
initialState: S,
1854+
url?: string,
1855+
): [S, (P) => void] {
1856+
// TODO: Not yet implemented
1857+
return [initialState, TODO_formStateDispatch];
1858+
}
1859+
1860+
function rerenderFormState<S, P>(
1861+
action: (S, P) => S,
1862+
initialState: S,
1863+
url?: string,
1864+
): [S, (P) => void] {
1865+
// TODO: Not yet implemented
1866+
return [initialState, TODO_formStateDispatch];
1867+
}
1868+
18381869
function pushEffect(
18391870
tag: HookFlags,
18401871
create: () => (() => void) | void,
@@ -2981,6 +3012,7 @@ if (enableUseEffectEventHook) {
29813012
if (enableFormActions && enableAsyncActions) {
29823013
(ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
29833014
throwInvalidHookError;
3015+
(ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
29843016
}
29853017
if (enableAsyncActions) {
29863018
(ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
@@ -3018,6 +3050,7 @@ if (enableUseEffectEventHook) {
30183050
if (enableFormActions && enableAsyncActions) {
30193051
(HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
30203052
useHostTransitionStatus;
3053+
(HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
30213054
}
30223055
if (enableAsyncActions) {
30233056
(HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
@@ -3055,6 +3088,7 @@ if (enableUseEffectEventHook) {
30553088
if (enableFormActions && enableAsyncActions) {
30563089
(HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
30573090
useHostTransitionStatus;
3091+
(HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
30583092
}
30593093
if (enableAsyncActions) {
30603094
(HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
@@ -3092,6 +3126,7 @@ if (enableUseEffectEventHook) {
30923126
if (enableFormActions && enableAsyncActions) {
30933127
(HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
30943128
useHostTransitionStatus;
3129+
(HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
30953130
}
30963131
if (enableAsyncActions) {
30973132
(HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
@@ -3276,6 +3311,16 @@ if (__DEV__) {
32763311
if (enableFormActions && enableAsyncActions) {
32773312
(HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
32783313
useHostTransitionStatus;
3314+
(HooksDispatcherOnMountInDEV: Dispatcher).useFormState =
3315+
function useFormState<S, P>(
3316+
action: (S, P) => S,
3317+
initialState: S,
3318+
url?: string,
3319+
): [S, (P) => void] {
3320+
currentHookNameInDev = 'useFormState';
3321+
mountHookTypesDev();
3322+
return mountFormState(action, initialState, url);
3323+
};
32793324
}
32803325
if (enableAsyncActions) {
32813326
(HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
@@ -3436,6 +3481,16 @@ if (__DEV__) {
34363481
if (enableFormActions && enableAsyncActions) {
34373482
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
34383483
useHostTransitionStatus;
3484+
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState =
3485+
function useFormState<S, P>(
3486+
action: (S, P) => S,
3487+
initialState: S,
3488+
url?: string,
3489+
): [S, (P) => void] {
3490+
currentHookNameInDev = 'useFormState';
3491+
updateHookTypesDev();
3492+
return mountFormState(action, initialState, url);
3493+
};
34393494
}
34403495
if (enableAsyncActions) {
34413496
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic =
@@ -3598,6 +3653,16 @@ if (__DEV__) {
35983653
if (enableFormActions && enableAsyncActions) {
35993654
(HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
36003655
useHostTransitionStatus;
3656+
(HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
3657+
function useFormState<S, P>(
3658+
action: (S, P) => S,
3659+
initialState: S,
3660+
url?: string,
3661+
): [S, (P) => void] {
3662+
currentHookNameInDev = 'useFormState';
3663+
updateHookTypesDev();
3664+
return updateFormState(action, initialState, url);
3665+
};
36013666
}
36023667
if (enableAsyncActions) {
36033668
(HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
@@ -3760,6 +3825,16 @@ if (__DEV__) {
37603825
if (enableFormActions && enableAsyncActions) {
37613826
(HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
37623827
useHostTransitionStatus;
3828+
(HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
3829+
function useFormState<S, P>(
3830+
action: (S, P) => S,
3831+
initialState: S,
3832+
url?: string,
3833+
): [S, (P) => void] {
3834+
currentHookNameInDev = 'useFormState';
3835+
updateHookTypesDev();
3836+
return rerenderFormState(action, initialState, url);
3837+
};
37633838
}
37643839
if (enableAsyncActions) {
37653840
(HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
@@ -3943,6 +4018,17 @@ if (__DEV__) {
39434018
if (enableFormActions && enableAsyncActions) {
39444019
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
39454020
useHostTransitionStatus;
4021+
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState =
4022+
function useFormState<S, P>(
4023+
action: (S, P) => S,
4024+
initialState: S,
4025+
url?: string,
4026+
): [S, (P) => void] {
4027+
currentHookNameInDev = 'useFormState';
4028+
warnInvalidHookAccess();
4029+
mountHookTypesDev();
4030+
return mountFormState(action, initialState, url);
4031+
};
39464032
}
39474033
if (enableAsyncActions) {
39484034
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
@@ -4130,6 +4216,17 @@ if (__DEV__) {
41304216
if (enableFormActions && enableAsyncActions) {
41314217
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
41324218
useHostTransitionStatus;
4219+
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
4220+
function useFormState<S, P>(
4221+
action: (S, P) => S,
4222+
initialState: S,
4223+
url?: string,
4224+
): [S, (P) => void] {
4225+
currentHookNameInDev = 'useFormState';
4226+
warnInvalidHookAccess();
4227+
updateHookTypesDev();
4228+
return updateFormState(action, initialState, url);
4229+
};
41334230
}
41344231
if (enableAsyncActions) {
41354232
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
@@ -4317,6 +4414,17 @@ if (__DEV__) {
43174414
if (enableFormActions && enableAsyncActions) {
43184415
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
43194416
useHostTransitionStatus;
4417+
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
4418+
function useFormState<S, P>(
4419+
action: (S, P) => S,
4420+
initialState: S,
4421+
url?: string,
4422+
): [S, (P) => void] {
4423+
currentHookNameInDev = 'useFormState';
4424+
warnInvalidHookAccess();
4425+
updateHookTypesDev();
4426+
return rerenderFormState(action, initialState, url);
4427+
};
43204428
}
43214429
if (enableAsyncActions) {
43224430
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =

packages/react-reconciler/src/ReactInternalTypes.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ export type HookType =
5353
| 'useSyncExternalStore'
5454
| 'useId'
5555
| 'useCacheRefresh'
56-
| 'useOptimistic';
56+
| 'useOptimistic'
57+
| 'useFormState';
5758

5859
export type ContextDependency<T> = {
5960
context: ReactContext<T>,
@@ -413,6 +414,11 @@ export type Dispatcher = {
413414
passthrough: S,
414415
reducer: ?(S, A) => S,
415416
) => [S, (A) => void],
417+
useFormState?: <S, P>(
418+
action: (S, P) => S,
419+
initialState: S,
420+
url?: string,
421+
) => [S, (P) => void],
416422
};
417423

418424
export type CacheDispatcher = {

0 commit comments

Comments
 (0)