Skip to content

Commit fd07ad1

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 dd480ef commit fd07ad1

File tree

12 files changed

+190
-1
lines changed

12 files changed

+190
-1
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/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(() => {
@@ -32,6 +33,7 @@ describe('ReactDOMFizzForm', () => {
3233
ReactDOMClient = require('react-dom/client');
3334
useFormStatus = require('react-dom').experimental_useFormStatus;
3435
useOptimistic = require('react').experimental_useOptimistic;
36+
useFormState = require('react').experimental_useFormState;
3537
act = require('internal-test-utils').act;
3638
container = document.createElement('div');
3739
document.body.appendChild(container);
@@ -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-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 = {

packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ let assertLog;
66
let useTransition;
77
let useState;
88
let useOptimistic;
9+
let useFormState;
910
let textCache;
1011

1112
describe('ReactAsyncActions', () => {
@@ -20,6 +21,7 @@ describe('ReactAsyncActions', () => {
2021
useTransition = React.useTransition;
2122
useState = React.useState;
2223
useOptimistic = React.experimental_useOptimistic;
24+
useFormState = React.experimental_useFormState;
2325

2426
textCache = new Map();
2527
});
@@ -1074,4 +1076,23 @@ describe('ReactAsyncActions', () => {
10741076
</>,
10751077
);
10761078
});
1079+
1080+
// @gate enableAsyncActions
1081+
test('useFormState exists', async () => {
1082+
// TODO: Not yet implemented. This just tests that the API is wired up.
1083+
1084+
async function action(state) {
1085+
return state;
1086+
}
1087+
1088+
function App() {
1089+
const [state] = useFormState(action, 0);
1090+
return <Text text={state} />;
1091+
}
1092+
1093+
const root = ReactNoop.createRoot();
1094+
await act(() => root.render(<App />));
1095+
assertLog([0]);
1096+
expect(root).toMatchRenderedOutput('0');
1097+
});
10771098
});

packages/react-server/src/ReactFizzHooks.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,10 @@ function unsupportedSetOptimisticState() {
542542
throw new Error('Cannot update optimistic state while rendering.');
543543
}
544544

545+
function unsupportedDispatchFormState() {
546+
throw new Error('Cannot update form state while rendering.');
547+
}
548+
545549
function useOptimistic<S, A>(
546550
passthrough: S,
547551
reducer: ?(S, A) => S,
@@ -550,6 +554,15 @@ function useOptimistic<S, A>(
550554
return [passthrough, unsupportedSetOptimisticState];
551555
}
552556

557+
function useFormState<S, P>(
558+
action: (S, P) => S,
559+
initialState: S,
560+
url?: string,
561+
): [S, (P) => void] {
562+
resolveCurrentlyRenderingComponent();
563+
return [initialState, unsupportedDispatchFormState];
564+
}
565+
553566
function useId(): string {
554567
const task: Task = (currentlyRenderingTask: any);
555568
const treeId = getTreeId(task.treeContext);
@@ -650,6 +663,7 @@ if (enableFormActions && enableAsyncActions) {
650663
}
651664
if (enableAsyncActions) {
652665
HooksDispatcher.useOptimistic = useOptimistic;
666+
HooksDispatcher.useFormState = useFormState;
653667
}
654668

655669
export let currentResponseState: null | ResponseState = (null: any);

packages/react/index.classic.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export {
5353
useInsertionEffect,
5454
useMemo,
5555
experimental_useOptimistic,
56+
experimental_useFormState,
5657
useReducer,
5758
useRef,
5859
useState,

packages/react/index.experimental.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {
5151
useLayoutEffect,
5252
useMemo,
5353
experimental_useOptimistic,
54+
experimental_useFormState,
5455
useReducer,
5556
useRef,
5657
useState,

packages/react/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export {
7676
useLayoutEffect,
7777
useMemo,
7878
experimental_useOptimistic,
79+
experimental_useFormState,
7980
useSyncExternalStore,
8081
useReducer,
8182
useRef,

packages/react/index.modern.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {
5151
useLayoutEffect,
5252
useMemo,
5353
experimental_useOptimistic,
54+
experimental_useFormState,
5455
useReducer,
5556
useRef,
5657
useState,

packages/react/src/React.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
use,
6161
useMemoCache,
6262
useOptimistic,
63+
useFormState,
6364
} from './ReactHooks';
6465
import {
6566
createElementWithValidation,
@@ -112,6 +113,7 @@ export {
112113
useLayoutEffect,
113114
useMemo,
114115
useOptimistic as experimental_useOptimistic,
116+
useFormState as experimental_useFormState,
115117
useSyncExternalStore,
116118
useReducer,
117119
useRef,

0 commit comments

Comments
 (0)