Skip to content

Commit

Permalink
Add React.useActionState (facebook#28491)
Browse files Browse the repository at this point in the history
## 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
(facebook#27980 (comment))
and [Allan
Lasser](https://allanlasser.com/posts/2024-01-26-avoid-using-reacts-useformstatus)
for their feedback and suggestions on `useFormStatus` hook.
  • Loading branch information
rickhanlonii authored and AndyPengc12 committed Apr 15, 2024
1 parent 2435ae2 commit a609503
Show file tree
Hide file tree
Showing 18 changed files with 262 additions and 48 deletions.
74 changes: 74 additions & 0 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
// This type check is for Flow only.
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
}
if (typeof Dispatcher.useActionState === 'function') {
// This type check is for Flow only.
Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
}
if (typeof Dispatcher.use === 'function') {
// This type check is for Flow only.
Dispatcher.use(
Expand Down Expand Up @@ -613,6 +617,75 @@ function useFormState<S, P>(
return [state, (payload: P) => {}, false];
}

function useActionState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const hook = nextHook(); // FormState
nextHook(); // PendingState
nextHook(); // ActionQueue
const stackError = new Error();
let value;
let debugInfo = null;
let error = null;

if (hook !== null) {
const actionResult = hook.memoizedState;
if (
typeof actionResult === 'object' &&
actionResult !== null &&
// $FlowFixMe[method-unbinding]
typeof actionResult.then === 'function'
) {
const thenable: Thenable<Awaited<S>> = (actionResult: any);
switch (thenable.status) {
case 'fulfilled': {
value = thenable.value;
debugInfo =
thenable._debugInfo === undefined ? null : thenable._debugInfo;
break;
}
case 'rejected': {
const rejectedError = thenable.reason;
error = rejectedError;
break;
}
default:
// If this was an uncached Promise we have to abandon this attempt
// but we can still emit anything up until this point.
error = SuspenseException;
debugInfo =
thenable._debugInfo === undefined ? null : thenable._debugInfo;
value = thenable;
}
} else {
value = (actionResult: any);
}
} else {
value = initialState;
}

hookLog.push({
displayName: null,
primitive: 'ActionState',
stackError: stackError,
value: value,
debugInfo: debugInfo,
});

if (error !== null) {
throw error;
}

// value being a Thenable is equivalent to error being not null
// i.e. we only reach this point with Awaited<S>
const state = ((value: any): Awaited<S>);

// TODO: support displaying pending value
return [state, (payload: P) => {}, false];
}

const Dispatcher: DispatcherType = {
use,
readContext,
Expand All @@ -635,6 +708,7 @@ const Dispatcher: DispatcherType = {
useDeferredValue,
useId,
useFormState,
useActionState,
};

// create a proxy to throw a custom error
Expand Down
13 changes: 9 additions & 4 deletions packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let ReactDOMServer;
let ReactDOMClient;
let useFormStatus;
let useOptimistic;
let useFormState;
let useActionState;

describe('ReactDOMFizzForm', () => {
beforeEach(() => {
Expand All @@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => {
ReactDOMServer = require('react-dom/server.browser');
ReactDOMClient = require('react-dom/client');
useFormStatus = require('react-dom').useFormStatus;
useFormState = require('react-dom').useFormState;
useOptimistic = require('react').useOptimistic;
act = require('internal-test-utils').act;
container = document.createElement('div');
document.body.appendChild(container);
if (__VARIANT__) {
// Remove after API is deleted.
useActionState = require('react-dom').useFormState;
} else {
useActionState = require('react').useActionState;
}
});

afterEach(() => {
Expand Down Expand Up @@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState returns initial state', async () => {
it('useActionState returns initial state', async () => {
async function action(state) {
return state;
}

function App() {
const [state] = useFormState(action, 0);
const [state] = useActionState(action, 0);
return state;
}

Expand Down
22 changes: 13 additions & 9 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ let SuspenseList;
let useSyncExternalStore;
let useSyncExternalStoreWithSelector;
let use;
let useFormState;
let useActionState;
let PropTypes;
let textCache;
let writable;
Expand Down Expand Up @@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => {
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
useFormState = ReactDOM.useFormState;

PropTypes = require('prop-types');
if (__VARIANT__) {
// Remove after API is deleted.
useActionState = ReactDOM.useFormState;
} else {
useActionState = React.useActionState;
}

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

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

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

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

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

// Because of the render phase update above, this component is evaluated
// multiple times (even during SSR), but it should only emit a single
// marker per useFormState instance.
const [formState] = useFormState(action, 0);
// marker per useActionState instance.
const [formState] = useActionState(action, 0);
const text = `${readText('Child')}:${formState}:${localState}`;
return (
<div id="child" ref={childRef}>
Expand Down
40 changes: 23 additions & 17 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('ReactDOMForm', () => {
let startTransition;
let textCache;
let useFormStatus;
let useFormState;
let useActionState;

beforeEach(() => {
jest.resetModules();
Expand All @@ -56,11 +56,17 @@ describe('ReactDOMForm', () => {
Suspense = React.Suspense;
startTransition = React.startTransition;
useFormStatus = ReactDOM.useFormStatus;
useFormState = ReactDOM.useFormState;
container = document.createElement('div');
document.body.appendChild(container);

textCache = new Map();

if (__VARIANT__) {
// Remove after API is deleted.
useActionState = ReactDOM.useFormState;
} else {
useActionState = React.useActionState;
}
});

function resolveText(text) {
Expand Down Expand Up @@ -962,7 +968,7 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState updates state asynchronously and queues multiple actions', async () => {
test('useActionState updates state asynchronously and queues multiple actions', async () => {
let actionCounter = 0;
async function action(state, type) {
actionCounter++;
Expand All @@ -982,7 +988,7 @@ describe('ReactDOMForm', () => {

let dispatch;
function App() {
const [state, _dispatch, isPending] = useFormState(action, 0);
const [state, _dispatch, isPending] = useActionState(action, 0);
dispatch = _dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
Expand Down Expand Up @@ -1023,10 +1029,10 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState supports inline actions', async () => {
test('useActionState supports inline actions', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch, isPending] = useFormState(async prevState => {
const [state, dispatch, isPending] = useActionState(async prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
Expand Down Expand Up @@ -1056,9 +1062,9 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: dispatch throws if called during render', async () => {
test('useActionState: dispatch throws if called during render', async () => {
function App() {
const [state, dispatch, isPending] = useFormState(async () => {}, 0);
const [state, dispatch, isPending] = useActionState(async () => {}, 0);
dispatch();
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
Expand All @@ -1076,7 +1082,7 @@ describe('ReactDOMForm', () => {
test('queues multiple actions and runs them in order', async () => {
let action;
function App() {
const [state, dispatch, isPending] = useFormState(
const [state, dispatch, isPending] = useActionState(
async (s, a) => await getText(a),
'A',
);
Expand Down Expand Up @@ -1106,10 +1112,10 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: works if action is sync', async () => {
test('useActionState: works if action is sync', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch, isPending] = useFormState(prevState => {
const [state, dispatch, isPending] = useActionState(prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
Expand Down Expand Up @@ -1139,10 +1145,10 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: can mix sync and async actions', async () => {
test('useActionState: can mix sync and async actions', async () => {
let action;
function App() {
const [state, dispatch, isPending] = useFormState((s, a) => a, 'A');
const [state, dispatch, isPending] = useActionState((s, a) => a, 'A');
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
Expand All @@ -1168,7 +1174,7 @@ describe('ReactDOMForm', () => {

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

let action;
function App() {
const [state, dispatch, isPending] = useFormState((s, a) => {
const [state, dispatch, isPending] = useActionState((s, a) => {
if (a.endsWith('!')) {
throw new Error(a);
}
Expand Down Expand Up @@ -1233,7 +1239,7 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: error handling (async action)', async () => {
test('useActionState: error handling (async action)', async () => {
let resetErrorBoundary;
class ErrorBoundary extends React.Component {
state = {error: null};
Expand All @@ -1251,7 +1257,7 @@ describe('ReactDOMForm', () => {

let action;
function App() {
const [state, dispatch, isPending] = useFormState(async (s, a) => {
const [state, dispatch, isPending] = useActionState(async (s, a) => {
const text = await getText(a);
if (text.endsWith('!')) {
throw new Error(text);
Expand Down
Loading

0 comments on commit a609503

Please sign in to comment.