Command Palette

Search for a command to run...

Server-side Form Validation

Ausath Ikram Oct 3, 2024

Validating forms is an essential part of web development. React 19 has a new feature called useActionState that will simplify how we handle server-side form validation. Combine it with Zod and Server Actions, we can create a powerful form validation system.

Current State of Making Forms

Before useActionState, we usually use a combination of useState to manage error and pending state (or useFormStatus to manage the pending state, only available on React canary channel based its latest docs as of October 3, 2024).

Here's an example with Next.js and Server Actions:

import { revalidatePath } from 'next/cache';

export default function Form() {
  async function createTodo(formData: FormData) {
    'use server';

    const rawFormData = {
      title: formData.get('title'),
      body: formData.get('body'),
    };

    await fetch(process.env.NEXT_PUBLIC_API_URL!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(rawFormData),
    });

    revalidatePath('/todos');
  }

  return (
    <form action={createTodo}>
      <input name="title" type="text" />
      <input name="body" type="text" />
      <button type="submit">Submit</button>
    </form>
  );
}

Now, let's add a pending state when the form is submitting with useFormStatus. This hook will return a pending boolean that we can use to modify the button for better user experience.

But we need to move the server action to a separate file to make it work.

// actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createTodo(formData: FormData) {
  const rawFormData = {
    title: formData.get('title'),
    body: formData.get('body'),
  };

  await fetch(process.env.NEXT_PUBLIC_API_URL!, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(rawFormData),
  });

  revalidatePath('/todos');
}
// form.tsx
'use client';

import { createTodo } from '@/lib/actions';
import { useFormStatus } from 'react-dom';
import { revalidatePath } from 'next/cache';

export default function Form() {
  const { pending } = useFormStatus();

  return (
    <form action={createTodo}>
      <input name="title" type="text" />
      <input name="body" type="text" />
      <button type="submit" disabled={pending}>
        {pending ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

At this point you can just add html required attribute for a simple client-side validation.

<input name="title" type="text" required />
<input name="body" type="text" required />

But what if you want to validate the form on the server? Sometimes validating should be done both on the client and server to prevent malicious users from bypassing the client-side validation.

We can use useState, but now that the server action is on another file, we need to pass the error state back from the server action to the form component.

One way to do this is to return a success state from the server action.

'use server';

import { revalidatePath } from 'next/cache';

export async function createTodo(formData: FormData) {
  const rawFormData = {
    title: formData.get('title'),
    body: formData.get('body'),
  };

  try {
    await fetch(process.env.NEXT_PUBLIC_API_URL!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(rawFormData),
    });
  } catch (error) {
    return {
      success: false,
      message: 'Failed to create todo',
    };
  }

  revalidatePath('/todos');
  return { success: true, message: 'Todo created successfully' };
}

Now on the client component, we can use the success state to show the error message.

'use client';
//...

export default function Form() {
  const { pending } = useFormStatus();
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(formData: FormData) {
    const result = await createTodo(formData);

    if (!result.success) {
      setError(result.message);
    } else {
      setError(null);
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="title" type="text" />
      <input name="body" type="text" />
      <button type="submit" disabled={pending}>
        {pending ? 'Submitting...' : 'Submit'}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

Note that now we use a handleSubmit function to call the server action, and we set the error state based on the result.

This only validates the result of the form submission, not the indiviual fields. Usually for better user experience and security, we should also validate each field before submitting the form. This is important, especially when working with multiple fields that has different constraints. For example, we may need to set the limit of the title to 50 characters, and the body to 500 characters.

Now we have to validate each fields, define our constraints, and set the error state for each field manually using if else statement and useState. If you have multiple fields, this would be a bit cumbersome.

To make it a bit simple, we can skip manually defining our constraints and use a library called Zod to define the form schema. This will make it easier to validate the form fields.

Using Zod for Form Validation on the Server

Zod is a schema definition library that can be used to validate data.

First, let's define our schema for the todo form, we will add this on the server action file.

import { z } from 'zod';
//...

const formSchema = z.object({
  title: z
    .string()
    .min(1, {
      message: 'Title is required',
    })
    .max(50, {
      message: 'Title must be less than 50 characters',
    }),
  body: z
    .string()
    .min(1, {
      message: 'Body is required',
    })
    .max(500, {
      message: 'Body must be less than 500 characters',
    }),
});

//...

Now we can use this schema to validate the form data before submitting the form.

export async function createTodo(formData: FormData) {
  const validatedFields = formSchema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  });

  // validate the fields based on the schema
  if (!validatedFields.success) {
    return {
      success: false,
      // add this new error object
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  try {
    await fetch(process.env.NEXT_PUBLIC_API_URL!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      // use the validated data
      body: JSON.stringify(validatedFields.data),
    });
  } catch (error) {
    //...
  }
  //...
}

This server action now returns an object with the success boolean and the errors object if the form validation fails. This errors object has the following shape:

errors: {
  title?: string[] | undefined;
  body?: string[] | undefined;
};

Notice how the type of each field is an array of strings, this is because each field could have multiple errors. Although in our case, we only have one error per field, either it's required or it's too long.

Now let's update the client component to show the error messages.

'use client';
//...

export default function Form() {
  const { pending } = useFormStatus();
  const [error, setError] = useState<string | null>(null);
  const [fieldErros, setFieldErrors] = useState<{
    title?: string[];
    body?: string[];
  } | null>(null);

  async function handleSubmit(formData: FormData) {
    const result = await createTodo(formData);

    if (!result.success) {
      setError(result.message ?? null);
      setFieldErrors(result.errors ?? null);
    } else {
      setError(null);
      setFieldErrors(null);
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="title" type="text" />
      {fieldErros?.title && <p>{fieldErros.title[0]}</p>}

      <input name="body" type="text" disabled={pending} />
      {fieldErros?.body && <p>{fieldErros.body[0]}</p>}

      <button type="submit" disabled={pending}>
        {pending ? 'Submitting...' : 'Submit'}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

This is already a good setup for form validation, but we can simplify this even further with useActionState.

Form Validation with useActionState

useActionState is a hook that allows you to update state based on the result of a form action. That's what the official docs says, let's see it in practice.

'use client';

import { useActionState } from 'react';
//...

export default function Form() {
  const { pending } = useFormStatus();
  const [state, formAction] = useActionState(createTodo, null);

  return (
    <form action={formAction}>
      <input name="title" type="text" />
      {state.errors?.title && <p>{state.errors.title[0]}</p>}

      <input name="body" type="text" disabled={pending} />
      {state.errors?.body && <p>{state.errors.body[0]}</p>}

      <button type="submit" disabled={pending}>
        {pending ? 'Submitting...' : 'Submit'}
      </button>

      {state.message && <p>{state.message}</p>}
    </form>
  );
}

We don't need to change anything on the server action, just add prevState to the function parameters as this is required.

export async function createTodo(prevState: any, formData: FormData) {
  //...
}

Leave the type of prevState to any for now, we'll get back to this later.

Let's break down the changes:

  • Handle the form action with useActionState. This hook takes the server action and the initial state as parameters, and will return the current state and the form action as value. I'll leave the initial state to undefined because this is the state before the form is submitted. but you can adjust it accordingly to your needs.
const [state, formAction] = useActionState(createTodo, undefined);
  • Instead of using a submit handler, we can call the formAction directly on the form action attribute.
<form action={formAction}></form>
  • Show the error message for each field with the errors array. Use the first error message for each field.
{state.errors?.title && <p>{state.errors.title[0]}</p>;}
{state.errors?.body && <p>{state.errors.body[0]}</p>;}
  • Show the message returned from the server action.
{state.message && <p>{state.message}</p>;}

That's basically it! you can now validate your forms with useActionState and Zod. This will make your form validation code cleaner and easier to maintain.

Oh and a bit of extra, useActionState returns another value called pending that you can use to disable the submit button while the form is submitting.

'use client';
//...

export default function Form() {
  const [state, formAction, pending] = useActionState(createTodo, null);

  return (
    <form action={formAction}>
      {/* ... */}

      <button type="submit" disabled={pending}>
        {pending ? 'Submitting...' : 'Submit'}
      </button>

      {/* ... */}
    </form>
  );
}

This will disable the submit button while the form is submitting, and show a "Submitting..." text on the button, just like what we did before with useFormStatus.

State Types

You may have noticed that we left the type of prevState to any. Let's define it now:

'use server';
//...

export type State = {
  success: boolean;
  message?: string | null;
  errors?: {
    title?: string[];
    body?: string[];
  };
};

export async function createTodo(prevState: State, formData: FormData) {
  //...
}
'use client';

import { State } from '@/lib/actions';
//...

export default function Form() {
  const initialState: State = {
    success: false,
    message: null,
    errors: {},
  };
  const [state, formAction, pending] = useActionState(createTodo, initialState);
  //...
}

This will make the code more type-safe and should be useful if you ever need to use the prevState in the server action.

Conclusion

To recap, we've learned how to validate forms on the server with Zod and return a state object from the server action, this lets us show the error message for each field using useState. Then we learned how to simplify the form validation code with useActionState, this will handle the form action and update the state based on the result of the action. We also learned how to define the state type for better type safety.

Note that this feature is only available on React canary channel. But it's a good idea to learn it and see how it can simplify your form validation code.

If you've made it this far, thank you for reading! I hope you find this post helpful. If you have any questions or feedback, feel free to leave a comment below.

GitHub Repo

You can find the full code for this implementation on my GitHub.

References

Blog