Skip to content

Commit 456d153

Browse files
authored
Client implementation of useFormState (#27278)
This implements useFormState in Fiber. (It does not include any progressive enhancement features; those will be added later.) useFormState is a hook for tracking state produced by async actions. It has a signature similar to useReducer, but instead of a reducer, it accepts an async action function. ```js async function action(prevState, payload) { // .. } const [state, dispatch] = useFormState(action, initialState) ``` Calling dispatch runs the async action and updates the state to the returned value. Async actions run before React's render cycle, so unlike reducers, they can contain arbitrary side effects.
1 parent 9a01c8b commit 456d153

File tree

6 files changed

+543
-120
lines changed

6 files changed

+543
-120
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function useFormStatus(): FormStatus {
7676
}
7777

7878
export function useFormState<S, P>(
79-
action: (S, P) => S,
79+
action: (S, P) => Promise<S>,
8080
initialState: S,
8181
url?: string,
8282
): [S, (P) => void] {

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

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe('ReactDOMForm', () => {
3535
let ReactDOMClient;
3636
let Scheduler;
3737
let assertLog;
38+
let waitForThrow;
3839
let useState;
3940
let Suspense;
4041
let startTransition;
@@ -50,6 +51,7 @@ describe('ReactDOMForm', () => {
5051
Scheduler = require('scheduler');
5152
act = require('internal-test-utils').act;
5253
assertLog = require('internal-test-utils').assertLog;
54+
waitForThrow = require('internal-test-utils').waitForThrow;
5355
useState = React.useState;
5456
Suspense = React.Suspense;
5557
startTransition = React.startTransition;
@@ -974,21 +976,137 @@ describe('ReactDOMForm', () => {
974976

975977
// @gate enableFormActions
976978
// @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;
979+
test('useFormState updates state asynchronously and queues multiple actions', async () => {
980+
let actionCounter = 0;
981+
async function action(state, type) {
982+
actionCounter++;
983+
984+
Scheduler.log(`Async action started [${actionCounter}]`);
985+
await getText(`Wait [${actionCounter}]`);
986+
987+
switch (type) {
988+
case 'increment':
989+
return state + 1;
990+
case 'decrement':
991+
return state - 1;
992+
default:
993+
return state;
994+
}
982995
}
983996

997+
let dispatch;
984998
function App() {
985-
const [state] = useFormState(action, 0);
999+
const [state, _dispatch] = useFormState(action, 0);
1000+
dispatch = _dispatch;
9861001
return <Text text={state} />;
9871002
}
9881003

9891004
const root = ReactDOMClient.createRoot(container);
9901005
await act(() => root.render(<App />));
9911006
assertLog([0]);
9921007
expect(container.textContent).toBe('0');
1008+
1009+
await act(() => dispatch('increment'));
1010+
assertLog(['Async action started [1]']);
1011+
expect(container.textContent).toBe('0');
1012+
1013+
// Dispatch a few more actions. None of these will start until the previous
1014+
// one finishes.
1015+
await act(() => dispatch('increment'));
1016+
await act(() => dispatch('decrement'));
1017+
await act(() => dispatch('increment'));
1018+
assertLog([]);
1019+
1020+
// Each action starts as soon as the previous one finishes.
1021+
// NOTE: React does not render in between these actions because they all
1022+
// update the same queue, which means they get entangled together. This is
1023+
// intentional behavior.
1024+
await act(() => resolveText('Wait [1]'));
1025+
assertLog(['Async action started [2]']);
1026+
await act(() => resolveText('Wait [2]'));
1027+
assertLog(['Async action started [3]']);
1028+
await act(() => resolveText('Wait [3]'));
1029+
assertLog(['Async action started [4]']);
1030+
await act(() => resolveText('Wait [4]'));
1031+
1032+
// Finally the last action finishes and we can render the result.
1033+
assertLog([2]);
1034+
expect(container.textContent).toBe('2');
1035+
});
1036+
1037+
// @gate enableFormActions
1038+
// @gate enableAsyncActions
1039+
test('useFormState supports inline actions', async () => {
1040+
let increment;
1041+
function App({stepSize}) {
1042+
const [state, dispatch] = useFormState(async prevState => {
1043+
return prevState + stepSize;
1044+
}, 0);
1045+
increment = dispatch;
1046+
return <Text text={state} />;
1047+
}
1048+
1049+
// Initial render
1050+
const root = ReactDOMClient.createRoot(container);
1051+
await act(() => root.render(<App stepSize={1} />));
1052+
assertLog([0]);
1053+
1054+
// Perform an action. This will increase the state by 1, as defined by the
1055+
// stepSize prop.
1056+
await act(() => increment());
1057+
assertLog([1]);
1058+
1059+
// Now increase the stepSize prop to 10. Subsequent steps will increase
1060+
// by this amount.
1061+
await act(() => root.render(<App stepSize={10} />));
1062+
assertLog([1]);
1063+
1064+
// Increment again. The state should increase by 10.
1065+
await act(() => increment());
1066+
assertLog([11]);
1067+
});
1068+
1069+
// @gate enableFormActions
1070+
// @gate enableAsyncActions
1071+
test('useFormState: dispatch throws if called during render', async () => {
1072+
function App() {
1073+
const [state, dispatch] = useFormState(async () => {}, 0);
1074+
dispatch();
1075+
return <Text text={state} />;
1076+
}
1077+
1078+
const root = ReactDOMClient.createRoot(container);
1079+
await act(async () => {
1080+
root.render(<App />);
1081+
await waitForThrow('Cannot update form state while rendering.');
1082+
});
1083+
});
1084+
1085+
// @gate enableFormActions
1086+
// @gate enableAsyncActions
1087+
test('useFormState: warns if action is not async', async () => {
1088+
let dispatch;
1089+
function App() {
1090+
const [state, _dispatch] = useFormState(() => {}, 0);
1091+
dispatch = _dispatch;
1092+
return <Text text={state} />;
1093+
}
1094+
1095+
const root = ReactDOMClient.createRoot(container);
1096+
await act(async () => {
1097+
root.render(<App />);
1098+
});
1099+
assertLog([0]);
1100+
1101+
expect(() => {
1102+
// This throws because React expects the action to return a promise.
1103+
expect(() => dispatch()).toThrow('Cannot read properties of undefined');
1104+
}).toErrorDev(
1105+
[
1106+
// In dev we also log a warning.
1107+
'The action passed to useFormState must be an async function',
1108+
],
1109+
{withoutStack: true},
1110+
);
9931111
});
9941112
});

packages/react-reconciler/src/ReactFiberAsyncAction.js

Lines changed: 94 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -34,97 +34,108 @@ let currentEntangledPendingCount: number = 0;
3434
let currentEntangledLane: Lane = NoLane;
3535

3636
export function requestAsyncActionContext<S>(
37-
actionReturnValue: mixed,
38-
finishedState: S,
39-
): Thenable<S> | S {
40-
if (
41-
actionReturnValue !== null &&
42-
typeof actionReturnValue === 'object' &&
43-
typeof actionReturnValue.then === 'function'
44-
) {
45-
// This is an async action.
46-
//
47-
// Return a thenable that resolves once the action scope (i.e. the async
48-
// function passed to startTransition) has finished running.
37+
actionReturnValue: Thenable<mixed>,
38+
// If this is provided, this resulting thenable resolves to this value instead
39+
// of the return value of the action. This is a perf trick to avoid composing
40+
// an extra async function.
41+
overrideReturnValue: S | null,
42+
): Thenable<S> {
43+
// This is an async action.
44+
//
45+
// Return a thenable that resolves once the action scope (i.e. the async
46+
// function passed to startTransition) has finished running.
4947

50-
const thenable: Thenable<mixed> = (actionReturnValue: any);
51-
let entangledListeners;
52-
if (currentEntangledListeners === null) {
53-
// There's no outer async action scope. Create a new one.
54-
entangledListeners = currentEntangledListeners = [];
55-
currentEntangledPendingCount = 0;
56-
currentEntangledLane = requestTransitionLane();
57-
} else {
58-
entangledListeners = currentEntangledListeners;
59-
}
48+
const thenable: Thenable<S> = (actionReturnValue: any);
49+
let entangledListeners;
50+
if (currentEntangledListeners === null) {
51+
// There's no outer async action scope. Create a new one.
52+
entangledListeners = currentEntangledListeners = [];
53+
currentEntangledPendingCount = 0;
54+
currentEntangledLane = requestTransitionLane();
55+
} else {
56+
entangledListeners = currentEntangledListeners;
57+
}
6058

61-
currentEntangledPendingCount++;
62-
let resultStatus = 'pending';
63-
let rejectedReason;
64-
thenable.then(
65-
() => {
66-
resultStatus = 'fulfilled';
67-
pingEngtangledActionScope();
68-
},
69-
error => {
70-
resultStatus = 'rejected';
71-
rejectedReason = error;
72-
pingEngtangledActionScope();
73-
},
74-
);
59+
currentEntangledPendingCount++;
7560

76-
// Create a thenable that represents the result of this action, but doesn't
77-
// resolve until the entire entangled scope has finished.
78-
//
79-
// Expressed using promises:
80-
// const [thisResult] = await Promise.all([thisAction, entangledAction]);
81-
// return thisResult;
82-
const resultThenable = createResultThenable<S>(entangledListeners);
61+
// Create a thenable that represents the result of this action, but doesn't
62+
// resolve until the entire entangled scope has finished.
63+
//
64+
// Expressed using promises:
65+
// const [thisResult] = await Promise.all([thisAction, entangledAction]);
66+
// return thisResult;
67+
const resultThenable = createResultThenable<S>(entangledListeners);
8368

84-
// Attach a listener to fill in the result.
85-
entangledListeners.push(() => {
86-
switch (resultStatus) {
87-
case 'fulfilled': {
88-
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
89-
fulfilledThenable.status = 'fulfilled';
90-
fulfilledThenable.value = finishedState;
91-
break;
92-
}
93-
case 'rejected': {
94-
const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
95-
rejectedThenable.status = 'rejected';
96-
rejectedThenable.reason = rejectedReason;
97-
break;
98-
}
99-
case 'pending':
100-
default: {
101-
// The listener above should have been called first, so `resultStatus`
102-
// should already be set to the correct value.
103-
throw new Error(
104-
'Thenable should have already resolved. This ' +
105-
'is a bug in React.',
106-
);
107-
}
108-
}
109-
});
69+
let resultStatus = 'pending';
70+
let resultValue;
71+
let rejectedReason;
72+
thenable.then(
73+
(value: S) => {
74+
resultStatus = 'fulfilled';
75+
resultValue = overrideReturnValue !== null ? overrideReturnValue : value;
76+
pingEngtangledActionScope();
77+
},
78+
error => {
79+
resultStatus = 'rejected';
80+
rejectedReason = error;
81+
pingEngtangledActionScope();
82+
},
83+
);
11084

111-
return resultThenable;
112-
} else {
113-
// This is not an async action, but it may be part of an outer async action.
114-
if (currentEntangledListeners === null) {
115-
return finishedState;
116-
} else {
117-
// Return a thenable that does not resolve until the entangled actions
118-
// have finished.
119-
const entangledListeners = currentEntangledListeners;
120-
const resultThenable = createResultThenable<S>(entangledListeners);
121-
entangledListeners.push(() => {
85+
// Attach a listener to fill in the result.
86+
entangledListeners.push(() => {
87+
switch (resultStatus) {
88+
case 'fulfilled': {
12289
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
12390
fulfilledThenable.status = 'fulfilled';
124-
fulfilledThenable.value = finishedState;
125-
});
126-
return resultThenable;
91+
fulfilledThenable.value = resultValue;
92+
break;
93+
}
94+
case 'rejected': {
95+
const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
96+
rejectedThenable.status = 'rejected';
97+
rejectedThenable.reason = rejectedReason;
98+
break;
99+
}
100+
case 'pending':
101+
default: {
102+
// The listener above should have been called first, so `resultStatus`
103+
// should already be set to the correct value.
104+
throw new Error(
105+
'Thenable should have already resolved. This ' + 'is a bug in React.',
106+
);
107+
}
127108
}
109+
});
110+
111+
return resultThenable;
112+
}
113+
114+
export function requestSyncActionContext<S>(
115+
actionReturnValue: mixed,
116+
// If this is provided, this resulting thenable resolves to this value instead
117+
// of the return value of the action. This is a perf trick to avoid composing
118+
// an extra async function.
119+
overrideReturnValue: S | null,
120+
): Thenable<S> | S {
121+
const resultValue: S =
122+
overrideReturnValue !== null
123+
? overrideReturnValue
124+
: (actionReturnValue: any);
125+
// This is not an async action, but it may be part of an outer async action.
126+
if (currentEntangledListeners === null) {
127+
return resultValue;
128+
} else {
129+
// Return a thenable that does not resolve until the entangled actions
130+
// have finished.
131+
const entangledListeners = currentEntangledListeners;
132+
const resultThenable = createResultThenable<S>(entangledListeners);
133+
entangledListeners.push(() => {
134+
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
135+
fulfilledThenable.status = 'fulfilled';
136+
fulfilledThenable.value = resultValue;
137+
});
138+
return resultThenable;
128139
}
129140
}
130141

0 commit comments

Comments
 (0)