Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add React.useActionState #28491

Merged
merged 2 commits into from
Mar 22, 2024
Merged

Conversation

rickhanlonii
Copy link
Member

@rickhanlonii rickhanlonii commented Mar 5, 2024

Overview

Depends on #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:

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:

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):

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:

import { useActionState, useRef } from "react";

function Form({ someAction }) {
  const ref = useRef(null);
  const [state, setState] = useState(null);
  const [isPending, startTransition] = 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:

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:

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:

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 for their feedback and suggestions on useFormStatus hook.

@facebook-github-bot facebook-github-bot added CLA Signed React Core Team Opened by a member of the React Core Team labels Mar 5, 2024
@rickhanlonii rickhanlonii changed the title Add React.useActionState Add React.useActionState Mar 5, 2024
@react-sizebot
Copy link

react-sizebot commented Mar 5, 2024

Comparing: 17eaaca...643e562

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js +0.06% 176.80 kB 176.90 kB +0.08% 54.90 kB 54.94 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js +0.06% 177.34 kB 177.44 kB +0.04% 55.26 kB 55.28 kB
facebook-www/ReactDOM-prod.classic.js +0.06% 593.95 kB 594.29 kB +0.02% 104.36 kB 104.38 kB
facebook-www/ReactDOM-prod.modern.js +0.06% 577.21 kB 577.55 kB +0.01% 101.41 kB 101.43 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js +6.54% 29.84 kB 31.79 kB +0.79% 7.69 kB 7.75 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js +6.54% 29.84 kB 31.79 kB +0.79% 7.69 kB 7.75 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js +6.54% 29.84 kB 31.79 kB +0.79% 7.69 kB 7.75 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.min.js +5.89% 9.95 kB 10.53 kB +1.07% 3.37 kB 3.40 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.min.js +5.89% 9.95 kB 10.53 kB +1.07% 3.37 kB 3.40 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.min.js +5.89% 9.95 kB 10.53 kB +1.07% 3.37 kB 3.40 kB
test_utils/ReactAllWarnings.js Deleted 66.60 kB 0.00 kB Deleted 16.28 kB 0.00 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js +6.54% 29.84 kB 31.79 kB +0.79% 7.69 kB 7.75 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js +6.54% 29.84 kB 31.79 kB +0.79% 7.69 kB 7.75 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js +6.54% 29.84 kB 31.79 kB +0.79% 7.69 kB 7.75 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.min.js +5.89% 9.95 kB 10.53 kB +1.07% 3.37 kB 3.40 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.min.js +5.89% 9.95 kB 10.53 kB +1.07% 3.37 kB 3.40 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.min.js +5.89% 9.95 kB 10.53 kB +1.07% 3.37 kB 3.40 kB
facebook-react-native/react/cjs/React-prod.js +1.24% 20.14 kB 20.39 kB +1.17% 5.06 kB 5.12 kB
facebook-react-native/react/cjs/React-profiling.js +1.23% 20.29 kB 20.54 kB +1.08% 5.08 kB 5.14 kB
facebook-www/ReactServer-prod.modern.js +1.08% 16.54 kB 16.71 kB +1.01% 4.34 kB 4.38 kB
oss-stable-semver/react/cjs/react.react-server.production.min.js +1.05% 7.55 kB 7.63 kB +0.54% 3.13 kB 3.15 kB
oss-stable/react/cjs/react.react-server.production.min.js +1.04% 7.58 kB 7.66 kB +0.63% 3.15 kB 3.17 kB
oss-stable-semver/react/cjs/react.production.min.js +0.92% 8.58 kB 8.66 kB +0.25% 3.24 kB 3.25 kB
oss-stable/react/cjs/react.production.min.js +0.92% 8.61 kB 8.69 kB +0.34% 3.26 kB 3.27 kB
facebook-www/React-prod.modern.js +0.88% 20.29 kB 20.47 kB +0.69% 5.09 kB 5.12 kB
facebook-www/React-prod.classic.js +0.86% 20.58 kB 20.76 kB +0.74% 5.15 kB 5.19 kB
oss-stable-semver/react/cjs/react.react-server.production.js +0.86% 33.52 kB 33.80 kB +0.75% 9.99 kB 10.06 kB
facebook-www/React-profiling.modern.js +0.86% 20.73 kB 20.90 kB +0.72% 5.16 kB 5.20 kB
oss-stable/react/cjs/react.react-server.production.js +0.86% 33.54 kB 33.83 kB +0.79% 10.01 kB 10.09 kB
facebook-www/React-profiling.classic.js +0.85% 21.01 kB 21.19 kB +0.69% 5.23 kB 5.27 kB
oss-experimental/react/cjs/react.production.min.js +0.83% 9.56 kB 9.64 kB +0.39% 3.56 kB 3.57 kB
oss-experimental/react/cjs/react.react-server.production.min.js +0.82% 9.62 kB 9.70 kB +0.47% 3.84 kB 3.86 kB
oss-stable-semver/react/cjs/react.production.js +0.75% 38.36 kB 38.65 kB +0.39% 10.71 kB 10.75 kB
oss-stable/react/cjs/react.production.js +0.75% 38.39 kB 38.68 kB +0.46% 10.73 kB 10.78 kB
oss-experimental/react/cjs/react.react-server.production.js +0.72% 39.92 kB 40.21 kB +0.65% 11.80 kB 11.87 kB
oss-experimental/react/cjs/react.production.js +0.70% 41.28 kB 41.57 kB +0.36% 11.49 kB 11.53 kB
oss-stable-semver/react/umd/react.profiling.min.js +0.60% 12.20 kB 12.27 kB +0.28% 4.72 kB 4.73 kB
oss-stable-semver/react/umd/react.production.min.js +0.60% 12.20 kB 12.27 kB +0.30% 4.72 kB 4.73 kB
oss-stable/react/umd/react.profiling.min.js +0.60% 12.22 kB 12.29 kB +0.34% 4.74 kB 4.76 kB
oss-stable/react/umd/react.production.min.js +0.60% 12.22 kB 12.30 kB +0.34% 4.74 kB 4.76 kB
oss-experimental/react-refresh/cjs/react-refresh-babel.production.min.js +0.58% 8.56 kB 8.61 kB +0.35% 2.88 kB 2.89 kB
oss-stable-semver/react-refresh/cjs/react-refresh-babel.production.min.js +0.58% 8.56 kB 8.61 kB +0.35% 2.88 kB 2.89 kB
oss-stable/react-refresh/cjs/react-refresh-babel.production.min.js +0.58% 8.56 kB 8.61 kB +0.35% 2.88 kB 2.89 kB
oss-experimental/react/umd/react.profiling.min.js +0.56% 13.11 kB 13.19 kB +0.32% 5.02 kB 5.04 kB
oss-experimental/react/umd/react.production.min.js +0.56% 13.11 kB 13.19 kB +0.34% 5.02 kB 5.04 kB
oss-stable-semver/react/cjs/react.react-server.development.js +0.38% 75.73 kB 76.02 kB +0.35% 21.04 kB 21.12 kB
oss-stable/react/cjs/react.react-server.development.js +0.38% 75.76 kB 76.05 kB +0.38% 21.07 kB 21.14 kB
oss-experimental/react/cjs/react.react-server.development.js +0.35% 82.27 kB 82.55 kB +0.31% 23.12 kB 23.19 kB
facebook-www/ReactServer-dev.modern.js +0.33% 93.89 kB 94.20 kB +0.32% 22.27 kB 22.34 kB
facebook-react-native/react/cjs/React-dev.js +0.32% 123.65 kB 124.04 kB +0.20% 29.54 kB 29.60 kB
oss-stable-semver/react/cjs/react.development.js +0.29% 98.43 kB 98.72 kB +0.15% 26.52 kB 26.56 kB
oss-stable/react/cjs/react.development.js +0.29% 98.46 kB 98.74 kB +0.16% 26.54 kB 26.59 kB
oss-experimental/react/cjs/react.development.js +0.28% 101.07 kB 101.36 kB +0.16% 27.38 kB 27.42 kB
facebook-www/React-dev.modern.js +0.25% 124.16 kB 124.48 kB +0.15% 29.56 kB 29.60 kB
facebook-www/React-dev.classic.js +0.25% 125.75 kB 126.06 kB +0.13% 29.94 kB 29.98 kB
oss-stable-semver/react/umd/react.development.js +0.25% 121.02 kB 121.32 kB +0.14% 31.09 kB 31.13 kB
oss-stable/react/umd/react.development.js +0.25% 121.04 kB 121.34 kB +0.16% 31.11 kB 31.16 kB
oss-experimental/react/umd/react.development.js +0.24% 123.76 kB 124.06 kB +0.17% 31.99 kB 32.04 kB
facebook-react-native/react-test-renderer/cjs/ReactTestRenderer-dev.js +0.24% 921.90 kB 924.12 kB +0.06% 183.07 kB 183.17 kB
oss-experimental/react-refresh/cjs/react-refresh-babel.production.js +0.24% 27.12 kB 27.18 kB +0.17% 6.00 kB 6.01 kB
oss-stable-semver/react-refresh/cjs/react-refresh-babel.production.js +0.24% 27.12 kB 27.18 kB +0.17% 6.00 kB 6.01 kB
oss-stable/react-refresh/cjs/react-refresh-babel.production.js +0.24% 27.12 kB 27.18 kB +0.17% 6.00 kB 6.01 kB
facebook-www/ReactTestRenderer-dev.modern.js +0.23% 942.76 kB 944.97 kB +0.05% 187.36 kB 187.46 kB
facebook-www/ReactTestRenderer-dev.classic.js +0.23% 942.76 kB 944.97 kB +0.05% 187.36 kB 187.46 kB
oss-experimental/react-refresh/cjs/react-refresh-babel.development.js +0.23% 27.34 kB 27.40 kB +0.15% 6.06 kB 6.07 kB
oss-stable-semver/react-refresh/cjs/react-refresh-babel.development.js +0.23% 27.34 kB 27.40 kB +0.15% 6.06 kB 6.07 kB
oss-stable/react-refresh/cjs/react-refresh-babel.development.js +0.23% 27.34 kB 27.40 kB +0.15% 6.06 kB 6.07 kB
oss-experimental/react-test-renderer/cjs/react-test-renderer.development.js +0.23% 812.84 kB 814.70 kB +0.05% 176.66 kB 176.75 kB
oss-stable-semver/react-test-renderer/cjs/react-test-renderer.development.js +0.23% 814.57 kB 816.43 kB +0.05% 177.12 kB 177.21 kB
oss-stable/react-test-renderer/cjs/react-test-renderer.development.js +0.23% 814.60 kB 816.46 kB +0.05% 177.15 kB 177.24 kB
oss-experimental/react-test-renderer/umd/react-test-renderer.development.js +0.23% 851.24 kB 853.18 kB +0.05% 178.61 kB 178.70 kB
oss-stable-semver/react-test-renderer/umd/react-test-renderer.development.js +0.23% 853.06 kB 855.00 kB +0.05% 179.08 kB 179.17 kB
oss-stable/react-test-renderer/umd/react-test-renderer.development.js +0.23% 853.09 kB 855.03 kB +0.05% 179.11 kB 179.20 kB
oss-stable-semver/react-art/cjs/react-art.development.js +0.22% 829.68 kB 831.54 kB +0.05% 179.65 kB 179.74 kB
oss-stable/react-art/cjs/react-art.development.js +0.22% 829.70 kB 831.57 kB +0.05% 179.68 kB 179.77 kB
oss-experimental/react-art/cjs/react-art.development.js +0.22% 835.93 kB 837.79 kB +0.05% 180.31 kB 180.39 kB
facebook-www/ReactART-dev.modern.js +0.21% 1,045.74 kB 1,047.95 kB +0.05% 204.71 kB 204.81 kB
facebook-www/ReactART-dev.classic.js +0.21% 1,057.94 kB 1,060.16 kB +0.05% 207.14 kB 207.24 kB
react-native/implementations/ReactFabric-dev.fb.js +0.21% 1,072.45 kB 1,074.67 kB +0.05% 212.95 kB 213.05 kB
oss-stable-semver/react-art/umd/react-art.development.js +0.21% 945.61 kB 947.55 kB +0.04% 198.99 kB 199.07 kB
oss-stable/react-art/umd/react-art.development.js +0.21% 945.64 kB 947.58 kB +0.04% 199.01 kB 199.10 kB
react-native/implementations/ReactNativeRenderer-dev.fb.js +0.20% 1,087.03 kB 1,089.24 kB +0.05% 216.81 kB 216.90 kB
oss-experimental/react-art/umd/react-art.development.js +0.20% 952.21 kB 954.15 kB +0.04% 199.68 kB 199.76 kB
facebook-www/ReactDOMServerStreaming-prod.modern.js = 208.62 kB 208.18 kB = 38.47 kB 38.45 kB
facebook-www/ReactDOMServer-prod.classic.js = 206.32 kB 205.88 kB = 37.40 kB 37.40 kB
facebook-www/ReactDOMServer-prod.modern.js = 204.77 kB 204.32 kB = 37.10 kB 37.10 kB
test_utils/ReactAllWarnings.js Deleted 66.60 kB 0.00 kB Deleted 16.28 kB 0.00 kB

Generated by 🚫 dangerJS against 643e562

@sophiebits
Copy link
Collaborator

In your PR description, what is the if (errorMessage != null) { for?

@meghsohor
Copy link

I guess there is a typo in the following code snippet:


image

@rickhanlonii
Copy link
Member Author

Good call @sophiebits, I was thinking it it's null you would do something with it like handle a re-direct, but in the other examples the action would handle that for you so it makes sense to drop it here.

Nice catch @meghsohor, fixed and I re-formatted all the examples to fix the syntax errors and formatting.

@tom-sherman
Copy link

Reading between the lines a bit here, could this be used allow you to opt out of Next.js' serial server action processing?

@rickhanlonii rickhanlonii force-pushed the rh/action-state branch 2 times, most recently from 54ab3ab to 651adba Compare March 7, 2024 05:16
@rickhanlonii
Copy link
Member Author

@tom-sherman I'm not familiar with the Next processing for server actions, but I don't think anything about this would change their implementation and that wasn't a motivating factor. It's the same hook, with a different name, a pending value, and just moved to the 'react' package.

@karlhorky
Copy link
Contributor

karlhorky commented Mar 7, 2024

In case anyone is reading the PR description examples and wondering (like I did) what types the first returned array element can be (errorMessage in the example, which looks like string | null type):

It looks like the first argument is just state, and it can be any type:

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

Also confirmed over here:

Correct, it's whatever data your action returns, just like useFormState. Since successful actions will typically navigate on success, it's probably most common to use the return value to show an error, which is the only reason I used an errorMessage in the example.

Source: https://twitter.com/rickhanlonii/status/1765592038026641571

@rickhanlonii
Copy link
Member Author

Thanks @karlhorky, I added the type to the reference section, updated the code examples to use state instead.

): [Awaited<S>, (P) => void, boolean] {
currentHookNameInDev = 'useActionState';
mountHookTypesDev();
return mountFormState(action, initialState, permalink);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Let's rename these to "action state" since the form state ones will go away eventually

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to do that in a follow up, it was in the original version but the PR was too big, so I'm splitting it out. Wanted to get this reviewed before submitting that to avoid annoying conflicts.

@rwieruch
Copy link

Great PR and great to see how fast the team responds to the feedback! Leaving just my 2 cents here from an educational perspective:

I ran into the same confusion when I wrote my blog post about Forms in Next. Now if I understand this PR correctly, useFormState will go away in favor or useActionState, but useFormStatus will stay around as a more fine-grained primitive (where I didn't have any usage yet, but probably more interesting for library/framework authors).

Now I have a kinda related question. In my article, I wanted to show how to trigger a reactive toast message once an action returns its response. But I had no indicator for the new formState, therefore I had to return a timestamp (read: timestamp: Date.now()) from the action as formState, so that I could reactively show a toast message in a custom hook:

import { useRef, useEffect } from 'react';
import { toast } from 'react-hot-toast';

type FormState = {
  message: string;
  fieldErrors: Record<string, string[] | undefined>;
  timestamp: number;
};

const useToastMessage = (formState: FormState) => {
  const prevTimestamp = useRef(formState.timestamp);

  const showToast =
    formState.message &&
    formState.timestamp !== prevTimestamp.current;

  useEffect(() => {
    if (showToast) {
      if (formState.status === 'ERROR') {
        toast.error(formState.message);
      } else {
        toast.success(formState.message);
      }

      prevTimestamp.current = formState.timestamp;
    }
  }, [formState, showToast]);
};

export { useToastMessage };

Since the returned message could be the same (e.g. "Successful request.") for two successive requests, I had to introduce the timestamp which does the check to see whether its a new instance of message to show as toast. Otherwise the toast would have only shown once:

formState.timestamp !== prevTimestamp.current

Now I see there would be a way around this with useActionState, e.g. checking if the pending state goes from true to false and showing the toast message (if there is any in the formState). Am I correct or is the best practice any other way? Thanks for you input! I want to showcase these use case in the best possible way :)

@rickhanlonii rickhanlonii merged commit 5c65b27 into facebook:main Mar 22, 2024
38 checks passed
@rickhanlonii rickhanlonii deleted the rh/action-state branch March 22, 2024 17:03
github-actions bot pushed a commit that referenced this pull request Mar 22, 2024
## 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.

DiffTrain build for [5c65b27](5c65b27)
@KATT
Copy link

KATT commented Mar 22, 2024

This is indeed what we're planning to do in React 19, but with a few caveats:

🥳 . All makes sense.

Appreciate your responses here. I'll elaborate more on the Payload 👇


Regarding the first point, I'm not sure your Payload proposal makes sense to me. What is the value of Payload after the submission has completed? It sounds like maybe you intend for it to be null, but in that case, the defaultValue in your example would also be empty, which conflicts with the idea of resetting back to defaultValue upon submission.

Server-side validation errors. Server actions can be completed without the form being "done".

Right now, any error response would have to return the full payload as well in order not to render with empty inputs.

I don't see many people doing forms that work nicely without JS without easy access to payload, it adds a quite a bit of grokking to know that you should return "last payload" on your server in order to render the invalid form with the last submission's values.

It doesn't conflict the resetting proposal if the order of operations is right in the "JS-enabled" perspective:

  1. Form is submitted / action is dispatched
  2. useActionState() now is a new tuple/object in pending with the new payload
  3. My <input /> is re-rendered with a new defaultValue (nothing happens since updating the defaultValue doesn't actually update it)
  4. Action completes, resets the form to their defaultValues (which is the latest payload since I used defaultValue={payload?.get('title')}

Might be some nuance there that needs tweaking to align it to how a browser without JS would work, especially in cases of frenzy-clicking submit buttons, but you get my point

Concrete example where this causes a problem

@acdlite, how would you suggest handling a form that returns validation errors on the server which would work nicely with and without JS and render inputs with the last submitted values? Is it straight-forward enough to get most people using React to do it "the right way"?

I'll try to describe it here in more detail:

// /app/posts/_actions.ts
'use server';
// [...] imports 

const postSchema = z.object({
  title: z.string().trim().min(10),
  content: z.string().trim().min(50),
});
export async function createPost(_: unknown, formData: FormData) {
  const result = postSchema.safeParse(Object.fromEntries(formData));
  if (!result.success) { 
    return {
      error: result.error.flatten(),
      // in order to render with the last values, we gotta return formData here :(
      formData,
    };
  }

  // i don't care about the result of successes since i just redirect
  const post = await prisma.post.create(result.data);
  redirect(`/post/${post.id}`);
}

Returning payload as part of State here isn't great:

  • adds extra complexity to the server actions to handle standard functionality
  • when JS is enabled it's completely unnecessary payload since it can be grabbed when dispatching
  • without returning the payload as part of State, you can't re-render the form's inputs with the right defaultValue/value
  • what if I had a file input with a big file? is that gonna be passed back-and-forth when it doesn't even need to be passed at all?

I just used an input validation error as an example here, but it could be a unique validation error from the database or something else that couldn't be handled gracefully in the browser before it hit the server

kassens added a commit that referenced this pull request Mar 22, 2024
Something went wrong when rebasing #28491  and renaming the hook.
gnoff added a commit to gnoff/next.js that referenced this pull request Mar 25, 2024
- facebook/react#28596
- facebook/react#28625
- facebook/react#28616
- facebook/react#28491
- facebook/react#28583
- facebook/react#28427
- facebook/react#28613
- facebook/react#28599
- facebook/react#28611
- facebook/react#28610
- facebook/react#28606
- facebook/react#28598
- facebook/react#28549
- facebook/react#28557
- facebook/react#28467
- facebook/react#28591
- facebook/react#28459
- facebook/react#28590
- facebook/react#28564
- facebook/react#28582
- facebook/react#28579
- facebook/react#28578
- facebook/react#28521
- facebook/react#28550
- facebook/react#28576
- facebook/react#28577
- facebook/react#28571
- facebook/react#28572
- facebook/react#28560
- facebook/react#28569
- facebook/react#28573
- facebook/react#28546
- facebook/react#28568
- facebook/react#28562
- facebook/react#28566
- facebook/react#28565
- facebook/react#28559
- facebook/react#28508
- facebook/react#20432
- facebook/react#28555
- facebook/react#24730
- facebook/react#28472
- facebook/react#27991
- facebook/react#28514
- facebook/react#28548
- facebook/react#28526
- facebook/react#28515
- facebook/react#28533
- facebook/react#28532
- facebook/react#28531
- facebook/react#28407
- facebook/react#28522
- facebook/react#28538
- facebook/react#28509
- facebook/react#28534
- facebook/react#28527
- facebook/react#28528
- facebook/react#28519
- facebook/react#28411
- facebook/react#28520
- facebook/react#28518
- facebook/react#28493
- facebook/react#28504
- facebook/react#28499
- facebook/react#28501
- facebook/react#28496
- facebook/react#28471
- facebook/react#28351
- facebook/react#28486
- facebook/react#28490
- facebook/react#28488
- facebook/react#28468
- facebook/react#28321
- facebook/react#28477
- facebook/react#28479
- facebook/react#28480
- facebook/react#28478
- facebook/react#28464
- facebook/react#28475
- facebook/react#28456
- facebook/react#28319
- facebook/react#28345
- facebook/react#28337
- facebook/react#28335
- facebook/react#28466
- facebook/react#28462
- facebook/react#28322
- facebook/react#28444
- facebook/react#28448
- facebook/react#28449
- facebook/react#28446
- facebook/react#28447
- facebook/react#24580
- facebook/react#28514
- facebook/react#28548
- facebook/react#28526
- facebook/react#28515
- facebook/react#28533
- facebook/react#28532
- facebook/react#28531
- facebook/react#28407
- facebook/react#28522
- facebook/react#28538
- facebook/react#28509
- facebook/react#28534
- facebook/react#28527
- facebook/react#28528
- facebook/react#28519
- facebook/react#28411
- facebook/react#28520
- facebook/react#28518
- facebook/react#28493
- facebook/react#28504
- facebook/react#28499
- facebook/react#28501
- facebook/react#28496
- facebook/react#28471
- facebook/react#28351
- facebook/react#28486
- facebook/react#28490
- facebook/react#28488
- facebook/react#28468
- facebook/react#28321
- facebook/react#28477
- facebook/react#28479
- facebook/react#28480
- facebook/react#28478
- facebook/react#28464
- facebook/react#28475
- facebook/react#28456
- facebook/react#28319
- facebook/react#28345
- facebook/react#28337
- facebook/react#28335
- facebook/react#28466
- facebook/react#28462
- facebook/react#28322
- facebook/react#28444
- facebook/react#28448
- facebook/react#28449
- facebook/react#28446
- facebook/react#28447
- facebook/react#24580
gnoff added a commit to gnoff/next.js that referenced this pull request Mar 25, 2024
- facebook/react#28596
- facebook/react#28625
- facebook/react#28616
- facebook/react#28491
- facebook/react#28583
- facebook/react#28427
- facebook/react#28613
- facebook/react#28599
- facebook/react#28611
- facebook/react#28610
- facebook/react#28606
- facebook/react#28598
- facebook/react#28549
- facebook/react#28557
- facebook/react#28467
- facebook/react#28591
- facebook/react#28459
- facebook/react#28590
- facebook/react#28564
- facebook/react#28582
- facebook/react#28579
- facebook/react#28578
- facebook/react#28521
- facebook/react#28550
- facebook/react#28576
- facebook/react#28577
- facebook/react#28571
- facebook/react#28572
- facebook/react#28560
- facebook/react#28569
- facebook/react#28573
- facebook/react#28546
- facebook/react#28568
- facebook/react#28562
- facebook/react#28566
- facebook/react#28565
- facebook/react#28559
- facebook/react#28508
- facebook/react#20432
- facebook/react#28555
- facebook/react#24730
- facebook/react#28472
- facebook/react#27991
- facebook/react#28514
- facebook/react#28548
- facebook/react#28526
- facebook/react#28515
- facebook/react#28533
- facebook/react#28532
- facebook/react#28531
- facebook/react#28407
- facebook/react#28522
- facebook/react#28538
- facebook/react#28509
- facebook/react#28534
- facebook/react#28527
- facebook/react#28528
- facebook/react#28519
- facebook/react#28411
- facebook/react#28520
- facebook/react#28518
- facebook/react#28493
- facebook/react#28504
- facebook/react#28499
- facebook/react#28501
- facebook/react#28496
- facebook/react#28471
- facebook/react#28351
- facebook/react#28486
- facebook/react#28490
- facebook/react#28488
- facebook/react#28468
- facebook/react#28321
- facebook/react#28477
- facebook/react#28479
- facebook/react#28480
- facebook/react#28478
- facebook/react#28464
- facebook/react#28475
- facebook/react#28456
- facebook/react#28319
- facebook/react#28345
- facebook/react#28337
- facebook/react#28335
- facebook/react#28466
- facebook/react#28462
- facebook/react#28322
- facebook/react#28444
- facebook/react#28448
- facebook/react#28449
- facebook/react#28446
- facebook/react#28447
- facebook/react#24580
@sebmarkbage
Copy link
Collaborator

sebmarkbage commented Apr 9, 2024

@KATT Your proposal/argument was compelling. We've spent some time evaluating a variant of your proposal (useFormStatus would have previous payload), but are currently leaning towards not adding it. Mainly due to hydration.

When you use the sent FormData directly to represent the "current" state of a form, there's no way to reset the form or control a field. E.g. filtering out invalid characters or upper case the field from the action. If that was ok, we could do something even better and just automatically set the value of a form field to the last set. Since it's not ok, that's why it's not a sufficient solution, you may need to move the source of truth into the state later on once you need to be able to control it from the action. That doesn't dismiss that you could start with the last-sent payload and then later upgrade to putting it inside useActionState as need arrises.

However, the main problem with surfacing the last sent payload is that we would still need to serialize it into the HTML for hydration. Since the current model is that even after submitting an MPA form, we can still hydrate the second attempt. It doesn't stay in no-JS land afterwards. So it wouldn't be better than the useActionState option and you still might need the useActionState option later when the need arrises. In that case we'd have to keep serializing the whole form - including files potentially - for hydration purposes in case you end up using it.

Therefore, it seems like it's better to stay with the model where previous state is returned from useActionState. That way you can control it and filter out anything that's not needed (such as Blobs).

There are a couple of things we can do to improve that experience though:

  • I've already added support for returning FormData from an Action. So that for a simple form you can just return the payload.
  • We have plans for an optimization that would exclude serializing the "previous state" argument if it's completely unused.
  • It would still serialize it in the return path though. For MPA form submissions this is still needed in the HTML for hydration purposes for the reasons mentioned above. However, for client submissions we can avoid sending this down if we already have a copy on the client using the Temporary References mechanism.

This mode is still opt-in though in that by default the form would not preserve user state unless you passed it through useActionState and did something like <input name="field" defaultValue={formData.has('field') ? formData.get('field') : lastSaved.field} />.

We've also evaluated a few other modes. Including we could automatically restore the last submitted field to inputs so that it's instead preserved by default and you have to call reset() explicitly inside a Server Action to clear it. However, once you get into that - afterwards you still back to square one. It's a bit leaky in terms of abstractions and it's also not necessarily always a better default.

I will also say that it's not expected that uncontrolled form fields is the way to do forms in React. Even the no-JS mode is not that great. We consider it more of an easy way to start, or good enough. But a good form should include controlled inputs, live-synchronized to localStorage or remotely (e.g. using useOptimistic) and deal with things like race conditions between submitting and returning the response (our resetting can blow away changes in this gap unless you disable form for example).

Therefore the expectation of these APIs is more that they're kind of "close to the metal" with some composition enhancements and you can get basic stuff done with uncontrolled forms alone - but mainly that you can build rich abstractions on top. The goal is to enable those libraries rather than having a really good outcome built-in. We have more examples and features planned for the advanced use cases too.

EdisonVan pushed a commit to EdisonVan/react that referenced this pull request Apr 15, 2024
## 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.
EdisonVan pushed a commit to EdisonVan/react that referenced this pull request Apr 15, 2024
Something went wrong when rebasing facebook#28491  and renaming the hook.
bigfootjon pushed a commit that referenced this pull request Apr 18, 2024
## 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.

DiffTrain build for commit 5c65b27.
eps1lon added a commit to vercel/next.js that referenced this pull request Apr 19, 2024
### React upstream changes

- facebook/react#28643
- facebook/react#28628
- facebook/react#28361
- facebook/react#28513
- facebook/react#28299
- facebook/react#28617
- facebook/react#28618
- facebook/react#28621
- facebook/react#28614
- facebook/react#28596
- facebook/react#28625
- facebook/react#28616
- facebook/react#28491
- facebook/react#28583
- facebook/react#28427
- facebook/react#28613
- facebook/react#28599
- facebook/react#28611
- facebook/react#28610
- facebook/react#28606
- facebook/react#28598
- facebook/react#28549
- facebook/react#28557
- facebook/react#28467
- facebook/react#28591
- facebook/react#28459
- facebook/react#28590
- facebook/react#28564
- facebook/react#28582
- facebook/react#28579
- facebook/react#28578
- facebook/react#28521
- facebook/react#28550
- facebook/react#28576
- facebook/react#28577
- facebook/react#28571
- facebook/react#28572
- facebook/react#28560
- facebook/react#28569
- facebook/react#28573
- facebook/react#28546
- facebook/react#28568
- facebook/react#28562
- facebook/react#28566
- facebook/react#28565
- facebook/react#28559
- facebook/react#28508
- facebook/react#20432
- facebook/react#28555
- facebook/react#24730
- facebook/react#28472
- facebook/react#27991
- facebook/react#28514
- facebook/react#28548
- facebook/react#28526
- facebook/react#28515
- facebook/react#28533
- facebook/react#28532
- facebook/react#28531
- facebook/react#28407
- facebook/react#28522
- facebook/react#28538
- facebook/react#28509
- facebook/react#28534
- facebook/react#28527
- facebook/react#28528
- facebook/react#28519
- facebook/react#28411
- facebook/react#28520
- facebook/react#28518
- facebook/react#28493
- facebook/react#28504
- facebook/react#28499
- facebook/react#28501
- facebook/react#28496
- facebook/react#28471
- facebook/react#28351
- facebook/react#28486
- facebook/react#28490
- facebook/react#28488
- facebook/react#28468
- facebook/react#28321
- facebook/react#28477
- facebook/react#28479
- facebook/react#28480
- facebook/react#28478
- facebook/react#28464
- facebook/react#28475
- facebook/react#28456
- facebook/react#28319
- facebook/react#28345
- facebook/react#28337
- facebook/react#28335
- facebook/react#28466
- facebook/react#28462
- facebook/react#28322
- facebook/react#28444
- facebook/react#28448
- facebook/react#28449
- facebook/react#28446
- facebook/react#28447
- facebook/react#24580
- facebook/react#28514
- facebook/react#28548
- facebook/react#28526
- facebook/react#28515
- facebook/react#28533
- facebook/react#28532
- facebook/react#28531
- facebook/react#28407
- facebook/react#28522
- facebook/react#28538
- facebook/react#28509
- facebook/react#28534
- facebook/react#28527
- facebook/react#28528
- facebook/react#28519
- facebook/react#28411
- facebook/react#28520
- facebook/react#28518
- facebook/react#28493
- facebook/react#28504
- facebook/react#28499
- facebook/react#28501
- facebook/react#28496
- facebook/react#28471
- facebook/react#28351
- facebook/react#28486
- facebook/react#28490
- facebook/react#28488
- facebook/react#28468
- facebook/react#28321
- facebook/react#28477
- facebook/react#28479
- facebook/react#28480
- facebook/react#28478
- facebook/react#28464
- facebook/react#28475
- facebook/react#28456
- facebook/react#28319
- facebook/react#28345
- facebook/react#28337
- facebook/react#28335
- facebook/react#28466
- facebook/react#28462
- facebook/react#28322
- facebook/react#28444
- facebook/react#28448
- facebook/react#28449
- facebook/react#28446
- facebook/react#28447
- facebook/react#24580
@malyzeli
Copy link

I think there is a typo in code example under Benefits section - it should be startTransition instead of setIsPending, right?

image

@sophiebits
Copy link
Collaborator

@malyzeli Thanks, fixed.

@muhrusdi
Copy link

Does useActionState can be used for right now in the canary version?, i got an error in the nextjs 14
Screenshot 2024-04-24 at 20 38 55

@eps1lon
Copy link
Collaborator

eps1lon commented Apr 24, 2024

Next.js hasn't caught up with the React version that supports this hook. You need to wait for a release of vercel/next.js#64798 to use this hook.

@devsmartproject
Copy link

In the benefits part where the useActionState

image

@devsmartproject
Copy link

Great PR and great to see how fast the team responds to the feedback! Leaving just my 2 cents here from an educational perspective:

I ran into the same confusion when I wrote my blog post about Forms in Next. Now if I understand this PR correctly, useFormState will go away in favor or useActionState, but useFormStatus will stay around as a more fine-grained primitive (where I didn't have any usage yet, but probably more interesting for library/framework authors).

Now I have a kinda related question. In my article, I wanted to show how to trigger a reactive toast message once an action returns its response. But I had no indicator for the new formState, therefore I had to return a timestamp (read: timestamp: Date.now()) from the action as formState, so that I could reactively show a toast message in a custom hook:

import { useRef, useEffect } from 'react';
import { toast } from 'react-hot-toast';

type FormState = {
  message: string;
  fieldErrors: Record<string, string[] | undefined>;
  timestamp: number;
};

const useToastMessage = (formState: FormState) => {
  const prevTimestamp = useRef(formState.timestamp);

  const showToast =
    formState.message &&
    formState.timestamp !== prevTimestamp.current;

  useEffect(() => {
    if (showToast) {
      if (formState.status === 'ERROR') {
        toast.error(formState.message);
      } else {
        toast.success(formState.message);
      }

      prevTimestamp.current = formState.timestamp;
    }
  }, [formState, showToast]);
};

export { useToastMessage };

Since the returned message could be the same (e.g. "Successful request.") for two successive requests, I had to introduce the timestamp which does the check to see whether its a new instance of message to show as toast. Otherwise the toast would have only shown once:

formState.timestamp !== prevTimestamp.current

Now I see there would be a way around this with useActionState, e.g. checking if the pending state goes from true to false and showing the toast message (if there is any in the formState). Am I correct or is the best practice any other way? Thanks for you input! I want to showcase these use case in the best possible way :)

Excellent article you published, really a detailed way of explanation, waiting for an update. Thank you

@logemann
Copy link

great work. Cant wait to use it w/ NextJS. I really like that the pending is merged into one hook (apart from other great things). Came here to see if useFormState can also be triggered manually within a react-hook-form handler just to see that the next iteration is even better. One little bonus of the rename is that there is no name clash with the hook from react-hook-form anymore. I know that was not the reason but a nice sideeffect.

@nonoakij
Copy link

nonoakij commented May 5, 2024

Thank you for adding this new hook—I'm really excited about it.
I have a question regarding its functionality.
Is it possible for the hook to accept a function with variadic arguments for the payload?
For example, could it handle a ServerAction function designed to take multiple arguments like this?

async function serverAction(arg1, arg2, arg3) {}

@KATT
Copy link

KATT commented May 5, 2024

Hey @sebmarkbage, thanks for getting back to me above and apologies for not replying back until now.

Before receiving your response, I created this reference repo where I highlighted the aforementioned issues in a practical example. Happy FormData can be serialized and forms are cleared by default now, those changes isomorphic behavior a bit clearer and easier to do well.

I still don't see the purpose of the first "last state"-argument on the action handlers - anything that it does solve for me in my app can be solved by adding a hidden input, but I'm looking forward to seeing these advanced use cases. I did ask Twitter to see if anyone could give me a compelling reason why it exists, but I received no compelling responses, so I and others seem to need some education. Currently, it feels like a leaky abstraction that should be hidden from me as a user.


Also before receiving your response, I created a bastardized version of the API I suggested above in this PR of the repo above by hacking Next.js' async storage and using a global context provider.

I did it by hydrating the submitted form's values (with omitted files) and I think it's worth the trade-off, considering it also would mean each proper "with JS" submission can always return zero bytes for the previous payload since it can simply be provided by sniffing FormData before sending it to the server (and it could include Files there too). In the case of uncontrolled forms, my proposed API only adds extra payload for no-JS form submissions, while the current API will add extra payload to all with-JS submissions that have errors.

I see that "good forms" are per definition "controlled forms" (and for big/complex forms it'd always be the case), but if the APIs would allow it, I think uncontrolled forms could become great forms.


The APIs still leave me wanting a lil' bit more, but I'm grateful for your work and your response to this; I know there are many considerations and I know that I still might be missing important nuances. Thank you. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet