react icon indicating copy to clipboard operation
react copied to clipboard

[React 19] Parameter order for server action when using `useActionState`

Open jonathanhefner opened this issue 1 year ago • 12 comments

Summary

useActionState currently requires accepting an additional state argument as the first parameter of a server action, like so (adapted from Next.js documentation):

// in app/actions.ts

-export async function updateUser(userId: string, formData: FormData) {
+export async function updateUser(prevState: any, userId: string, formData: FormData) {

This interferes with the use case of binding leading arguments to the action (adapted from Next.js documentation):

// in app/client-component.tsx

import { updateUser } from './actions'

const updateUserWithId = updateUser.bind(null, 'some id')
const [state, formAction] = useActionState(updateUserWithId, 'initial state')

For the above example to work, updateUser would need to accept the previous state as the middle parameter:

// in app/actions.ts

-export async function updateUser(prevState: any, userId: string, formData: FormData) {
+export async function updateUser(userId: string, prevState: any, formData: FormData) {

Additionally, the server action might not actually care about the previous state. For example, if the action validates form data and the state is just a validation error message, then the action likely doesn't care about previous error messages. In such cases, it would be nicer DX to be able to omit the state parameter.

Proposal

Make useActionState pass the previous state to the server action as the last argument, always:

// in app/actions.ts

-export async function updateUser(prevState: any, userId: string, formData: FormData) {
+export async function updateUser(userId: string, formData: FormData, prevState: any) {

This might also enable skipping the serialization of the previous state when the server action does not use it (i.e. when action.length < 2).

jonathanhefner avatar Oct 25 '24 18:10 jonathanhefner

Is bind not working for actions passed to useActionState or is this only about the order of parameters?

This would be a breaking change so the bar for this change is very high.

eps1lon avatar Oct 28 '24 19:10 eps1lon

Is bind not working for actions passed to useActionState or is this only about the order of parameters?

This would be a breaking change so the bar for this change is very high.

Let me preface by saying: I think form submission and validation will be a major use case of useActionState.

If I have an action f(x1, x2, ..., formData) that processes a form submission and returns validation errors, then, to use useActionState, I must create a new function fState(prevState, x1, x2, ..., formData).

If I want to bind one argument, then I must create another new function fStateBind1(x1, prevState, x2, ..., formData).

If I want to bind two arguments, then I must create another new function fStateBind2(x1, x2, prevState, ..., formData).

The above works, but it is poor DX. It also requires serializing and transmitting state that the action doesn't use.

All of that could be avoided by passing prevState as the final argument.

But, if it is too disruptive to change useActionState, what about adding another hook to address this use case? Something like useFormAction, useActionResult, or useActionOutput?

jonathanhefner avatar Oct 29 '24 15:10 jonathanhefner

This is the how I have solved in my application. I removed bind method and used like this.

my client component code

    const param =1;
    const [formState, formAction] = useActionState((state:FormState, formData: FormData) => handleSubmit(state, formData, param), INITAIL_STATE, undefined)
    console.log({formState});
    return (
    <form action={formAction}>
        <input name="username" id="username" />
        <button type="submit">Submit</button>
    </form>
    )

server action code

'use server'

export async function handleSubmit(prevState: object, formData: FormData, param?: number) {
    
    const data = {name: formData.get('username')};
    console.log({prevState})
    console.log({formData})
    console.log({param})
    return {
        ...prevState,
        data: data
    }
}

lokeshdaiya avatar Nov 20 '24 09:11 lokeshdaiya

@lokeshdaiya The nextjs docs list two downsides to this. "the value will be part of the rendered HTML and will not be encoded." and ".bind works in both Server and Client Components. It also supports progressive enhancement." from the docs What do you think of this, like is it important to have the values 'encoded'?

enzzzooo avatar Nov 25 '24 02:11 enzzzooo

@lokeshdaiya The nextjs docs list two downsides to this. "the value will be part of the rendered HTML and will not be encoded." and ".bind works in both Server and Client Components. It also supports progressive enhancement." from the docs What do you think of this, like is it important to have the values 'encoded'?

In the official documentation it talks about .bind method and hidden fields. I have suggested another way which solves the passing additional argument to action methods.

With hidden fields the drawback is it will be part of dom and anybody can see this by inspecting through developer tools.

lokeshdaiya avatar Nov 25 '24 13:11 lokeshdaiya

Are developers requiring this in order to handle complex forms? I have a multi-step form that is rendered across multiple components, such that it requires storing the state in a controlled manner. Calling action={formAction} naively doesn't pass the form fields that are no longer being rendered, so I need a way of passing it up to the server action. I figured bind() was the right tool for the job here, and ended up on this discussion.

anthonyalayo avatar Nov 26 '24 21:11 anthonyalayo

I think that in the working and valid case of

async function updateUser(userId: string, prevState: any, formData: FormData) {}

the OP wrongly frames the prevState as the middle argument, while one must take it as the pair together with the formData, so actually those are the last two arguments.

Binding works here with the useActionState as usuall. It's a javascript feature, which simply eats the arguments from the start, so if we plan to use the action with the hook, we need to reserve the last 2 args.

export function updateUser(userId: string,  _prevState: unknown, formData: FormData) {
  return {
    userId,
    name: formData.get("name"),
  };
}

export function UpdateUserForm({ userId }: Props) {
  const action = updateUser.bind(null, userId);

  const [state, formAction, pending] = useActionState(action, null);

  return (
    <form action={formAction}>
      <input name="name" placeholder="Name" defaultValue="Jeff" />
      <button type="submit">{pending ? "submitting..." : "Submit"}</button>
      <p>{state?.userId ? `submitted with id ${state.userId}` : null}</p>
    </form>
  );
}

Yeah this makes the updateUser action ugly, so we can get rid of the prevState by adding a helper function:

// skip1 creates a function which will ignore its first argument (here the prevState)
// and will call our action (fn) without it
const skip1 = (fn) => (_, ...args) => fn(...args);

// note prevState is not used anymore
export function updateUser(userId: string, formData: FormData) {
  return {
    userId,
    name: formData.get("name"),
  };
}

// regular bind
const action = updateUser.bind(null, userId);

// skip the prevState which the useActionState will pass...
const [state, formAction, pending] = useActionState(skip1(action), null);

React api is fine here, it's up to users to pick their style.

MiroslavPetrik avatar Jan 17 '25 11:01 MiroslavPetrik

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

github-actions[bot] avatar Jun 07 '25 22:06 github-actions[bot]

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

I still think this is a DX problem.

jonathanhefner avatar Jun 09 '25 19:06 jonathanhefner

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

github-actions[bot] avatar Sep 07 '25 21:09 github-actions[bot]

Bump.

jonathanhefner avatar Sep 08 '25 12:09 jonathanhefner

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

github-actions[bot] avatar Dec 07 '25 14:12 github-actions[bot]

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!

github-actions[bot] avatar Dec 14 '25 14:12 github-actions[bot]