Skip to content

Commit 5c65b27

Browse files
authored
Add React.useActionState (#28491)
## Overview _Depends on https://github.com/facebook/react/pull/28514_ This PR adds a new React hook called `useActionState` to replace and improve the ReactDOM `useFormState` hook. ## Motivation This hook intends to fix some of the confusion and limitations of the `useFormState` hook. The `useFormState` hook is only exported from the `ReactDOM` package and implies that it is used only for the state of `<form>` actions, similar to `useFormStatus` (which is only for `<form>` element status). This leads to understandable confusion about why `useFormState` does not provide a `pending` state value like `useFormStatus` does. The key insight is that the `useFormState` hook does not actually return the state of any particular form at all. Instead, it returns the state of the _action_ passed to the hook, wrapping it and returning a trackable action to add to a form, and returning the last returned value of the action given. In fact, `useFormState` doesn't need to be used in a `<form>` at all. Thus, adding a `pending` value to `useFormState` as-is would thus be confusing because it would only return the pending state of the _action_ given, not the `<form>` the action is passed to. Even if we wanted to tie them together, the returned `action` can be passed to multiple forms, creating confusing and conflicting pending states during multiple form submissions. Additionally, since the action is not related to any particular `<form>`, the hook can be used in any renderer - not only `react-dom`. For example, React Native could use the hook to wrap an action, pass it to a component that will unwrap it, and return the form result state and pending state. It's renderer agnostic. To fix these issues, this PR: - Renames `useFormState` to `useActionState` - Adds a `pending` state to the returned tuple - Moves the hook to the `'react'` package ## Reference The `useFormState` hook allows you to track the pending state and return value of a function (called an "action"). The function passed can be a plain JavaScript client function, or a bound server action to a reference on the server. It accepts an optional `initialState` value used for the initial render, and an optional `permalink` argument for renderer specific pre-hydration handling (such as a URL to support progressive hydration in `react-dom`). Type: ```ts function useActionState<State>( action: (state: Awaited<State>) => State | Promise<State>, initialState: Awaited<State>, permalink?: string, ): [state: Awaited<State>, dispatch: () => void, boolean]; ``` The hook returns a tuple with: - `state`: the last state the action returned - `dispatch`: the method to call to dispatch the wrapped action - `pending`: the pending state of the action and any state updates contained Notably, state updates inside of the action dispatched are wrapped in a transition to keep the page responsive while the action is completing and the UI is updated based on the result. ## Usage The `useActionState` hook can be used similar to `useFormState`: ```js import { useActionState } from "react"; // not react-dom function Form({ formAction }) { const [state, action, isPending] = useActionState(formAction); return ( <form action={action}> <input type="email" name="email" disabled={isPending} /> <button type="submit" disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </form> ); } ``` But it doesn't need to be used with a `<form/>` (neither did `useFormState`, hence the confusion): ```js import { useActionState, useRef } from "react"; function Form({ someAction }) { const ref = useRef(null); const [state, action, isPending] = useActionState(someAction); async function handleSubmit() { // See caveats below await action({ email: ref.current.value }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` ## Benefits One of the benefits of using this hook is the automatic tracking of the return value and pending states of the wrapped function. For example, the above example could be accomplished via: ```js import { useActionState, useRef } from "react"; function Form({ someAction }) { const ref = useRef(null); const [state, setState] = useState(null); const [isPending, setIsPending] = useTransition(); function handleSubmit() { startTransition(async () => { const response = await someAction({ email: ref.current.value }); setState(response); }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` However, this hook adds more benefits when used with render specific elements like react-dom `<form>` elements and Server Action. With `<form>` elements, React will automatically support replay actions on the form if it is submitted before hydration has completed, providing a form of partial progressive enhancement: enhancement for when javascript is enabled but not ready. Additionally, with the `permalink` argument and Server Actions, frameworks can provide full progressive enhancement support, submitting the form to the URL provided along with the FormData from the form. On submission, the Server Action will be called during the MPA navigation, similar to any raw HTML app, server rendered, and the result returned to the client without any JavaScript on the client. ## Caveats There are a few Caveats to this new hook: **Additional state update**: Since we cannot know whether you use the pending state value returned by the hook, the hook will always set the `isPending` state at the beginning of the first chained action, resulting in an additional state update similar to `useTransition`. In the future a type-aware compiler could optimize this for when the pending state is not accessed. **Pending state is for the action, not the handler**: The difference is subtle but important, the pending state begins when the return action is dispatched and will revert back after all actions and transitions have settled. The mechanism for this under the hook is the same as useOptimisitic. Concretely, what this means is that the pending state of `useActionState` will not represent any actions or sync work performed before dispatching the action returned by `useActionState`. Hopefully this is obvious based on the name and shape of the API, but there may be some temporary confusion. As an example, let's take the above example and await another action inside of it: ```js import { useActionState, useRef } from "react"; function Form({ someAction, someOtherAction }) { const ref = useRef(null); const [state, action, isPending] = useActionState(someAction); async function handleSubmit() { await someOtherAction(); // The pending state does not start until this call. await action({ email: ref.current.value }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` Since the pending state is related to the action, and not the handler or form it's attached to, the pending state only changes when the action is dispatched. To solve, there are two options. First (recommended): place the other function call inside of the action passed to `useActionState`: ```js import { useActionState, useRef } from "react"; function Form({ someAction, someOtherAction }) { const ref = useRef(null); const [state, action, isPending] = useActionState(async (data) => { // Pending state is true already. await someOtherAction(); return someAction(data); }); async function handleSubmit() { // The pending state starts at this call. await action({ email: ref.current.value }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` For greater control, you can also wrap both in a transition and use the `isPending` state of the transition: ```js import { useActionState, useTransition, useRef } from "react"; function Form({ someAction, someOtherAction }) { const ref = useRef(null); // isPending is used from the transition wrapping both action calls. const [isPending, startTransition] = useTransition(); // isPending not used from the individual action. const [state, action] = useActionState(someAction); async function handleSubmit() { startTransition(async () => { // The transition pending state has begun. await someOtherAction(); await action({ email: ref.current.value }); }); } return ( <div> <input ref={ref} type="email" name="email" disabled={isPending} /> <button onClick={handleSubmit} disabled={isPending}> Submit </button> {state.errorMessage && <p>{state.errorMessage}</p>} </div> ); } ``` A similar technique using `useOptimistic` is preferred over using `useTransition` directly, and is left as an exercise to the reader. ## Thanks Thanks to @ryanflorence @mjackson @wesbos (#27980 (comment)) and [Allan Lasser](https://allanlasser.com/posts/2024-01-26-avoid-using-reacts-useformstatus) for their feedback and suggestions on `useFormStatus` hook.
1 parent fabd6d3 commit 5c65b27

File tree

18 files changed

+262
-48
lines changed

18 files changed

+262
-48
lines changed

packages/react-debug-tools/src/ReactDebugHooks.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
106106
// This type check is for Flow only.
107107
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
108108
}
109+
if (typeof Dispatcher.useActionState === 'function') {
110+
// This type check is for Flow only.
111+
Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
112+
}
109113
if (typeof Dispatcher.use === 'function') {
110114
// This type check is for Flow only.
111115
Dispatcher.use(
@@ -613,6 +617,75 @@ function useFormState<S, P>(
613617
return [state, (payload: P) => {}, false];
614618
}
615619

620+
function useActionState<S, P>(
621+
action: (Awaited<S>, P) => S,
622+
initialState: Awaited<S>,
623+
permalink?: string,
624+
): [Awaited<S>, (P) => void, boolean] {
625+
const hook = nextHook(); // FormState
626+
nextHook(); // PendingState
627+
nextHook(); // ActionQueue
628+
const stackError = new Error();
629+
let value;
630+
let debugInfo = null;
631+
let error = null;
632+
633+
if (hook !== null) {
634+
const actionResult = hook.memoizedState;
635+
if (
636+
typeof actionResult === 'object' &&
637+
actionResult !== null &&
638+
// $FlowFixMe[method-unbinding]
639+
typeof actionResult.then === 'function'
640+
) {
641+
const thenable: Thenable<Awaited<S>> = (actionResult: any);
642+
switch (thenable.status) {
643+
case 'fulfilled': {
644+
value = thenable.value;
645+
debugInfo =
646+
thenable._debugInfo === undefined ? null : thenable._debugInfo;
647+
break;
648+
}
649+
case 'rejected': {
650+
const rejectedError = thenable.reason;
651+
error = rejectedError;
652+
break;
653+
}
654+
default:
655+
// If this was an uncached Promise we have to abandon this attempt
656+
// but we can still emit anything up until this point.
657+
error = SuspenseException;
658+
debugInfo =
659+
thenable._debugInfo === undefined ? null : thenable._debugInfo;
660+
value = thenable;
661+
}
662+
} else {
663+
value = (actionResult: any);
664+
}
665+
} else {
666+
value = initialState;
667+
}
668+
669+
hookLog.push({
670+
displayName: null,
671+
primitive: 'ActionState',
672+
stackError: stackError,
673+
value: value,
674+
debugInfo: debugInfo,
675+
});
676+
677+
if (error !== null) {
678+
throw error;
679+
}
680+
681+
// value being a Thenable is equivalent to error being not null
682+
// i.e. we only reach this point with Awaited<S>
683+
const state = ((value: any): Awaited<S>);
684+
685+
// TODO: support displaying pending value
686+
return [state, (payload: P) => {}, false];
687+
}
688+
616689
const Dispatcher: DispatcherType = {
617690
use,
618691
readContext,
@@ -635,6 +708,7 @@ const Dispatcher: DispatcherType = {
635708
useDeferredValue,
636709
useId,
637710
useFormState,
711+
useActionState,
638712
};
639713

640714
// create a proxy to throw a custom error

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ let ReactDOMServer;
2323
let ReactDOMClient;
2424
let useFormStatus;
2525
let useOptimistic;
26-
let useFormState;
26+
let useActionState;
2727

2828
describe('ReactDOMFizzForm', () => {
2929
beforeEach(() => {
@@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => {
3232
ReactDOMServer = require('react-dom/server.browser');
3333
ReactDOMClient = require('react-dom/client');
3434
useFormStatus = require('react-dom').useFormStatus;
35-
useFormState = require('react-dom').useFormState;
3635
useOptimistic = require('react').useOptimistic;
3736
act = require('internal-test-utils').act;
3837
container = document.createElement('div');
3938
document.body.appendChild(container);
39+
if (__VARIANT__) {
40+
// Remove after API is deleted.
41+
useActionState = require('react-dom').useFormState;
42+
} else {
43+
useActionState = require('react').useActionState;
44+
}
4045
});
4146

4247
afterEach(() => {
@@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => {
474479

475480
// @gate enableFormActions
476481
// @gate enableAsyncActions
477-
it('useFormState returns initial state', async () => {
482+
it('useActionState returns initial state', async () => {
478483
async function action(state) {
479484
return state;
480485
}
481486

482487
function App() {
483-
const [state] = useFormState(action, 0);
488+
const [state] = useActionState(action, 0);
484489
return state;
485490
}
486491

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ let SuspenseList;
3030
let useSyncExternalStore;
3131
let useSyncExternalStoreWithSelector;
3232
let use;
33-
let useFormState;
33+
let useActionState;
3434
let PropTypes;
3535
let textCache;
3636
let writable;
@@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => {
8989
if (gate(flags => flags.enableSuspenseList)) {
9090
SuspenseList = React.unstable_SuspenseList;
9191
}
92-
useFormState = ReactDOM.useFormState;
93-
9492
PropTypes = require('prop-types');
93+
if (__VARIANT__) {
94+
// Remove after API is deleted.
95+
useActionState = ReactDOM.useFormState;
96+
} else {
97+
useActionState = React.useActionState;
98+
}
9599

96100
const InternalTestUtils = require('internal-test-utils');
97101
waitForAll = InternalTestUtils.waitForAll;
@@ -6137,8 +6141,8 @@ describe('ReactDOMFizzServer', () => {
61376141

61386142
// @gate enableFormActions
61396143
// @gate enableAsyncActions
6140-
it('useFormState hydrates without a mismatch', async () => {
6141-
// This is testing an implementation detail: useFormState emits comment
6144+
it('useActionState hydrates without a mismatch', async () => {
6145+
// This is testing an implementation detail: useActionState emits comment
61426146
// nodes into the SSR stream, so this checks that they are handled correctly
61436147
// during hydration.
61446148

@@ -6148,7 +6152,7 @@ describe('ReactDOMFizzServer', () => {
61486152

61496153
const childRef = React.createRef(null);
61506154
function Form() {
6151-
const [state] = useFormState(action, 0);
6155+
const [state] = useActionState(action, 0);
61526156
const text = `Child: ${state}`;
61536157
return (
61546158
<div id="child" ref={childRef}>
@@ -6191,7 +6195,7 @@ describe('ReactDOMFizzServer', () => {
61916195

61926196
// @gate enableFormActions
61936197
// @gate enableAsyncActions
6194-
it("useFormState hydrates without a mismatch if there's a render phase update", async () => {
6198+
it("useActionState hydrates without a mismatch if there's a render phase update", async () => {
61956199
async function action(state) {
61966200
return state;
61976201
}
@@ -6205,8 +6209,8 @@ describe('ReactDOMFizzServer', () => {
62056209

62066210
// Because of the render phase update above, this component is evaluated
62076211
// multiple times (even during SSR), but it should only emit a single
6208-
// marker per useFormState instance.
6209-
const [formState] = useFormState(action, 0);
6212+
// marker per useActionState instance.
6213+
const [formState] = useActionState(action, 0);
62106214
const text = `${readText('Child')}:${formState}:${localState}`;
62116215
return (
62126216
<div id="child" ref={childRef}>

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

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe('ReactDOMForm', () => {
4141
let startTransition;
4242
let textCache;
4343
let useFormStatus;
44-
let useFormState;
44+
let useActionState;
4545

4646
beforeEach(() => {
4747
jest.resetModules();
@@ -56,11 +56,17 @@ describe('ReactDOMForm', () => {
5656
Suspense = React.Suspense;
5757
startTransition = React.startTransition;
5858
useFormStatus = ReactDOM.useFormStatus;
59-
useFormState = ReactDOM.useFormState;
6059
container = document.createElement('div');
6160
document.body.appendChild(container);
6261

6362
textCache = new Map();
63+
64+
if (__VARIANT__) {
65+
// Remove after API is deleted.
66+
useActionState = ReactDOM.useFormState;
67+
} else {
68+
useActionState = React.useActionState;
69+
}
6470
});
6571

6672
function resolveText(text) {
@@ -962,7 +968,7 @@ describe('ReactDOMForm', () => {
962968

963969
// @gate enableFormActions
964970
// @gate enableAsyncActions
965-
test('useFormState updates state asynchronously and queues multiple actions', async () => {
971+
test('useActionState updates state asynchronously and queues multiple actions', async () => {
966972
let actionCounter = 0;
967973
async function action(state, type) {
968974
actionCounter++;
@@ -982,7 +988,7 @@ describe('ReactDOMForm', () => {
982988

983989
let dispatch;
984990
function App() {
985-
const [state, _dispatch, isPending] = useFormState(action, 0);
991+
const [state, _dispatch, isPending] = useActionState(action, 0);
986992
dispatch = _dispatch;
987993
const pending = isPending ? 'Pending ' : '';
988994
return <Text text={pending + state} />;
@@ -1023,10 +1029,10 @@ describe('ReactDOMForm', () => {
10231029

10241030
// @gate enableFormActions
10251031
// @gate enableAsyncActions
1026-
test('useFormState supports inline actions', async () => {
1032+
test('useActionState supports inline actions', async () => {
10271033
let increment;
10281034
function App({stepSize}) {
1029-
const [state, dispatch, isPending] = useFormState(async prevState => {
1035+
const [state, dispatch, isPending] = useActionState(async prevState => {
10301036
return prevState + stepSize;
10311037
}, 0);
10321038
increment = dispatch;
@@ -1056,9 +1062,9 @@ describe('ReactDOMForm', () => {
10561062

10571063
// @gate enableFormActions
10581064
// @gate enableAsyncActions
1059-
test('useFormState: dispatch throws if called during render', async () => {
1065+
test('useActionState: dispatch throws if called during render', async () => {
10601066
function App() {
1061-
const [state, dispatch, isPending] = useFormState(async () => {}, 0);
1067+
const [state, dispatch, isPending] = useActionState(async () => {}, 0);
10621068
dispatch();
10631069
const pending = isPending ? 'Pending ' : '';
10641070
return <Text text={pending + state} />;
@@ -1076,7 +1082,7 @@ describe('ReactDOMForm', () => {
10761082
test('queues multiple actions and runs them in order', async () => {
10771083
let action;
10781084
function App() {
1079-
const [state, dispatch, isPending] = useFormState(
1085+
const [state, dispatch, isPending] = useActionState(
10801086
async (s, a) => await getText(a),
10811087
'A',
10821088
);
@@ -1106,10 +1112,10 @@ describe('ReactDOMForm', () => {
11061112

11071113
// @gate enableFormActions
11081114
// @gate enableAsyncActions
1109-
test('useFormState: works if action is sync', async () => {
1115+
test('useActionState: works if action is sync', async () => {
11101116
let increment;
11111117
function App({stepSize}) {
1112-
const [state, dispatch, isPending] = useFormState(prevState => {
1118+
const [state, dispatch, isPending] = useActionState(prevState => {
11131119
return prevState + stepSize;
11141120
}, 0);
11151121
increment = dispatch;
@@ -1139,10 +1145,10 @@ describe('ReactDOMForm', () => {
11391145

11401146
// @gate enableFormActions
11411147
// @gate enableAsyncActions
1142-
test('useFormState: can mix sync and async actions', async () => {
1148+
test('useActionState: can mix sync and async actions', async () => {
11431149
let action;
11441150
function App() {
1145-
const [state, dispatch, isPending] = useFormState((s, a) => a, 'A');
1151+
const [state, dispatch, isPending] = useActionState((s, a) => a, 'A');
11461152
action = dispatch;
11471153
const pending = isPending ? 'Pending ' : '';
11481154
return <Text text={pending + state} />;
@@ -1168,7 +1174,7 @@ describe('ReactDOMForm', () => {
11681174

11691175
// @gate enableFormActions
11701176
// @gate enableAsyncActions
1171-
test('useFormState: error handling (sync action)', async () => {
1177+
test('useActionState: error handling (sync action)', async () => {
11721178
let resetErrorBoundary;
11731179
class ErrorBoundary extends React.Component {
11741180
state = {error: null};
@@ -1186,7 +1192,7 @@ describe('ReactDOMForm', () => {
11861192

11871193
let action;
11881194
function App() {
1189-
const [state, dispatch, isPending] = useFormState((s, a) => {
1195+
const [state, dispatch, isPending] = useActionState((s, a) => {
11901196
if (a.endsWith('!')) {
11911197
throw new Error(a);
11921198
}
@@ -1233,7 +1239,7 @@ describe('ReactDOMForm', () => {
12331239

12341240
// @gate enableFormActions
12351241
// @gate enableAsyncActions
1236-
test('useFormState: error handling (async action)', async () => {
1242+
test('useActionState: error handling (async action)', async () => {
12371243
let resetErrorBoundary;
12381244
class ErrorBoundary extends React.Component {
12391245
state = {error: null};
@@ -1251,7 +1257,7 @@ describe('ReactDOMForm', () => {
12511257

12521258
let action;
12531259
function App() {
1254-
const [state, dispatch, isPending] = useFormState(async (s, a) => {
1260+
const [state, dispatch, isPending] = useActionState(async (s, a) => {
12551261
const text = await getText(a);
12561262
if (text.endsWith('!')) {
12571263
throw new Error(text);

0 commit comments

Comments
 (0)