Skip to content

Commit 1a7c347

Browse files
committed
Scaffolding for useFormState
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: 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 856dc5e commit 1a7c347

15 files changed

+203
-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: 23 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,27 @@ describe('ReactDOMFizzForm', () => {
470472
expect(container.textContent).toBe('hi');
471473
});
472474

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

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

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

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,
@@ -2984,6 +3015,7 @@ if (enableFormActions && enableAsyncActions) {
29843015
}
29853016
if (enableAsyncActions) {
29863017
(ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
3018+
(ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
29873019
}
29883020

29893021
const HooksDispatcherOnMount: Dispatcher = {
@@ -3021,6 +3053,7 @@ if (enableFormActions && enableAsyncActions) {
30213053
}
30223054
if (enableAsyncActions) {
30233055
(HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
3056+
(HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
30243057
}
30253058

30263059
const HooksDispatcherOnUpdate: Dispatcher = {
@@ -3058,6 +3091,7 @@ if (enableFormActions && enableAsyncActions) {
30583091
}
30593092
if (enableAsyncActions) {
30603093
(HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
3094+
(HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
30613095
}
30623096

30633097
const HooksDispatcherOnRerender: Dispatcher = {
@@ -3095,6 +3129,7 @@ if (enableFormActions && enableAsyncActions) {
30953129
}
30963130
if (enableAsyncActions) {
30973131
(HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
3132+
(HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
30983133
}
30993134

31003135
let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
@@ -3287,6 +3322,16 @@ if (__DEV__) {
32873322
mountHookTypesDev();
32883323
return mountOptimistic(passthrough, reducer);
32893324
};
3325+
(HooksDispatcherOnMountInDEV: Dispatcher).useFormState =
3326+
function useFormState<S, P>(
3327+
action: (S, P) => S,
3328+
initialState: S,
3329+
url?: string,
3330+
): [S, (P) => void] {
3331+
currentHookNameInDev = 'useFormState';
3332+
mountHookTypesDev();
3333+
return mountFormState(action, initialState, url);
3334+
};
32903335
}
32913336

32923337
HooksDispatcherOnMountWithHookTypesInDEV = {
@@ -3447,6 +3492,16 @@ if (__DEV__) {
34473492
updateHookTypesDev();
34483493
return mountOptimistic(passthrough, reducer);
34493494
};
3495+
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState =
3496+
function useFormState<S, P>(
3497+
action: (S, P) => S,
3498+
initialState: S,
3499+
url?: string,
3500+
): [S, (P) => void] {
3501+
currentHookNameInDev = 'useFormState';
3502+
updateHookTypesDev();
3503+
return mountFormState(action, initialState, url);
3504+
};
34503505
}
34513506

34523507
HooksDispatcherOnUpdateInDEV = {
@@ -3609,6 +3664,16 @@ if (__DEV__) {
36093664
updateHookTypesDev();
36103665
return updateOptimistic(passthrough, reducer);
36113666
};
3667+
(HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
3668+
function useFormState<S, P>(
3669+
action: (S, P) => S,
3670+
initialState: S,
3671+
url?: string,
3672+
): [S, (P) => void] {
3673+
currentHookNameInDev = 'useFormState';
3674+
updateHookTypesDev();
3675+
return updateFormState(action, initialState, url);
3676+
};
36123677
}
36133678

36143679
HooksDispatcherOnRerenderInDEV = {
@@ -3771,6 +3836,16 @@ if (__DEV__) {
37713836
updateHookTypesDev();
37723837
return rerenderOptimistic(passthrough, reducer);
37733838
};
3839+
(HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
3840+
function useFormState<S, P>(
3841+
action: (S, P) => S,
3842+
initialState: S,
3843+
url?: string,
3844+
): [S, (P) => void] {
3845+
currentHookNameInDev = 'useFormState';
3846+
updateHookTypesDev();
3847+
return rerenderFormState(action, initialState, url);
3848+
};
37743849
}
37753850

37763851
InvalidNestedHooksDispatcherOnMountInDEV = {
@@ -3955,6 +4030,17 @@ if (__DEV__) {
39554030
mountHookTypesDev();
39564031
return mountOptimistic(passthrough, reducer);
39574032
};
4033+
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState =
4034+
function useFormState<S, P>(
4035+
action: (S, P) => S,
4036+
initialState: S,
4037+
url?: string,
4038+
): [S, (P) => void] {
4039+
currentHookNameInDev = 'useFormState';
4040+
warnInvalidHookAccess();
4041+
mountHookTypesDev();
4042+
return mountFormState(action, initialState, url);
4043+
};
39584044
}
39594045

39604046
InvalidNestedHooksDispatcherOnUpdateInDEV = {
@@ -4142,6 +4228,17 @@ if (__DEV__) {
41424228
updateHookTypesDev();
41434229
return updateOptimistic(passthrough, reducer);
41444230
};
4231+
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
4232+
function useFormState<S, P>(
4233+
action: (S, P) => S,
4234+
initialState: S,
4235+
url?: string,
4236+
): [S, (P) => void] {
4237+
currentHookNameInDev = 'useFormState';
4238+
warnInvalidHookAccess();
4239+
updateHookTypesDev();
4240+
return updateFormState(action, initialState, url);
4241+
};
41454242
}
41464243

41474244
InvalidNestedHooksDispatcherOnRerenderInDEV = {
@@ -4329,5 +4426,16 @@ if (__DEV__) {
43294426
updateHookTypesDev();
43304427
return rerenderOptimistic(passthrough, reducer);
43314428
};
4429+
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
4430+
function useFormState<S, P>(
4431+
action: (S, P) => S,
4432+
initialState: S,
4433+
url?: string,
4434+
): [S, (P) => void] {
4435+
currentHookNameInDev = 'useFormState';
4436+
warnInvalidHookAccess();
4437+
updateHookTypesDev();
4438+
return rerenderFormState(action, initialState, url);
4439+
};
43324440
}
43334441
}

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)