Forms

Learn how to use ZSA with forms.

Basic Form

You can use a server action directly with a regular form. Set the type to "formData" to indicate that the input is a FormData object.

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const produceNewMessage = createServerAction()
  .input(
    z.object({
      name: z.string().min(5),
    }),
    {
      type: "formData", 
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name
  })

Then on the client you can pass the action prop to the form to handle the form submission.

form-example.tsx
"use client"

import { produceNewMessage } from "./actions";

export default function FormExample() {
    return (
        <form
            action={produceNewMessage} 
        >
            <label>
                Name:
                <input type="text" name="name" required />
            </label>
            <button type="submit">Submit</button>
        </form>
    );
}

Submit with useServerAction

However if you do it this way you will not have access to the data and error variables. To get the data and error you can use the useServerAction hook.

basic-form-example.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useServerAction } from "zsa-react"
import { produceNewMessage } from "./actions"

export const BasicForm = () => {
  const { isPending, execute, isSuccess, data, isError, error } =
    useServerAction(produceNewMessage) 

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Form Example</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form
          className="flex flex-col gap-4"
          onSubmit={async (event) => {
            event.preventDefault()
            const form = event.currentTarget

            const formData = new FormData(form) 
            const [data, err] = await execute(formData) 

            if (err) {
              // handle error
              return
            }

            form.reset()
          }}
        >
          <Input type="text" name="name" placeholder="Enter your name..." />
          <Button className="w-full" type="submit" disabled={isPending}>
            Submit
          </Button>
          {isPending && <div>Loading...</div>}
          {isSuccess && <div>Success: {JSON.stringify(data)}</div>}
          {isError && <div>Error: {JSON.stringify(error.fieldErrors)}</div>}
        </form>
      </CardContent>
    </Card>
  )
}

Here is the result:

Form Example

In this example, we create a basic form and handle the form submission manually. We use the useServerAction hook to execute the ZSA action and handle the success and error states accordingly.

Form Actions

useServerAction also supports executing a form action directly. This is useful when you want to handle form submissions in your React components.

To do this, you can use the executeFormAction function passed back by useServerAction hook.

First, make sure you have the input type set to "formData" in your server action.

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const produceNewMessage = createServerAction()
  .input(
    z.object({
      name: z.string().min(5),
    }),
    {
      type: "formData", 
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name
  })

Then, you can use the executeFormAction function to execute the form action.

form-example.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useServerAction } from "zsa-react"
import { produceNewMessage } from "./actions"

export const ExecuteFormAction = () => {
  const { isPending, executeFormAction, isSuccess, data, isError, error } =
    useServerAction(produceNewMessage) 

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Form Example</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form className="flex flex-col gap-4" action={executeFormAction}>
          <Input type="text" name="name" placeholder="Enter your name..." />
          <Button className="w-full" type="submit" disabled={isPending}>
            Submit
          </Button>
          {isPending && <div>Loading...</div>}
          {isSuccess && <div>Success: {JSON.stringify(data)}</div>}
          {isError && <div>Error: {JSON.stringify(error.fieldErrors)}</div>}
        </form>
      </CardContent>
    </Card>
  )
}

Here is the result:

Form Example

React Hook Form

The useServerAction hook from the zsa-react library allows you to easily integrate ZSA with the popular react-hook-form library. Here's an example of how to use it:

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const produceNewMessage = createServerAction()
  .input(
    z.object({
      name: z.string().min(5),
    }),
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name
  })

Then on the client side, you can use the useServerAction hook to execute the action and handle the result.

react-hook-form.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { useServerAction } from "zsa-react"
import { produceNewMessage } from "./actions"

const formSchema = z.object({
  name: z.string().min(2, {
    message: "Name must be at least 2 characters.",
  }),
})

export function ReactHookForm() {
  const { isPending, execute, data, error } = useServerAction(produceNewMessage) 

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: "",
    },
  })

  async function onSubmit(values: z.infer<typeof formSchema>) {
    const [data, err] = await execute(values) 

    if (err) {
      // show a toast or something
      return
    }

    form.reset({ name: "" })
  }

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Form Example</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Name</FormLabel>
                  <FormControl>
                    <Input placeholder="shadcn" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button disabled={isPending} type="submit" className="w-full">
              {isPending ? "Saving..." : "Save"}
            </Button>
          </form>
        </Form>
        {data && <div>Message: {data}</div>}
        {error && <div>Error: {JSON.stringify(error.fieldErrors)}</div>}
      </CardContent>
    </Card>
  )
}

Here is the result:

Form Example

In this example, we define a form schema using Zod and create a form using react-hook-form. We then use the useServerAction hook to execute the ZSA action when the form is submitted. The isPending variable can be used to display a loading state while the action is being executed.

useActionState

useActionState was previously called useFormState. It is now deprecated and will be removed in a future release.

The useActionState hook from ZSA allows you to perform actions and manage the action state. Make sure to set the type to "state" to indicate that the input is a (previousState, formData) tuple.

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const produceNewMessage = createServerAction()
  .input(
    z.object({
      name: z.string().min(5),
    }),
    {
      type: "state", 
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name
  })

Then on the client side, you can use the useActionState hook to execute the action and handle the result.

use-action-state.tsx
"use client"

import { useActionState } from "react";
import { produceNewMessage } from "./actions";

export default function UseActionStateExample() {
  let [[data, err], submitAction, isPending] = useActionState( 
    produceNewMessage, 
    [null, null] // or [initialData, null]
  ); 

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Use Form State</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form action={submitAction} className="flex flex-col gap-4">
          <Input name="name" placeholder="Enter your name..." />
          <Button disabled={isPending}>Create message</Button>
        </form>
        {isPending && <div>Loading...</div>}
        {data && <p>Message: {data}</p>}
        {err && <div>Error: {JSON.stringify(err)}</div>}
      </CardContent>
    </Card>
  );
}

Here is the result:

Use Action State


This example supports progressive enhancement. Give it a shot, try submitting after disabling Javascript!

In this example, we use the useActionState hook to perform the produceNewMessage action and manage the action state. The submitAction function is passed to the form's action prop to handle the form submission. The isPending, data, and err variables can be used to display loading, success, and error states respectively.

Custom State

What if you want to control the state of the form yourself? Let's see how we can do that. This example will show how to append messages to an array.

First, make sure you are using formData as the input type.

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

const produceNewMessageAction = createServerAction()
  .input(
    z.object({
      name: z.string(),
    }),
    {
      type: "formData", 
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name
  })

export const produceNewMessage = async (
  previousState: string[],
  formData: FormData
) => {
  const [data, err] = await produceNewMessageAction(formData) 

  if (err) {
    // handle error
    return previousState 
  }

  return [...previousState, data] 
}

Now we can use the useActionState hook to control the state of the form.

use-action-state-custom-state.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useActionState } from "react"
import { produceNewMessage } from "./actions"

export default function UseActionCustomStateExample() {
  let [messages, submitAction] = useActionState(produceNewMessage, [ 
    "my initial message", 
  ]) 

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Use Action Custom State</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form action={submitAction} className="flex flex-col gap-4">
          <Input name="name" placeholder="Enter your name..." />
          <Button>Create message</Button>
        </form>
        <h1>Messages:</h1>
        <div>
          {messages.map((message, index) => (
            <div key={index}>{message}</div>
          ))}
        </div>
      </CardContent>
    </Card>
  )
}

Let's check out the result!

Use Action Custom State

Messages:

my initial message

Skip Input Parsing

What if you want to receive a raw payload, such as FormData object, and handle it yourself? This is useful for example when using the @conform-to/react library. Let's see how we can do that.

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const produceNewMessage = createServerAction()
  .input(
    z.custom<FormData>(), 
    {
      type: "state", 
      skipInputParsing: true
    }
  )
  .handler(async ({ input }) => {
    const payload = Object.fromEntries(input)

    const result = z.object({
      name: z.string().min(5)
    }).safeParse(payload)

    if (result.error) {
      throw result.error
    }

    await new Promise((resolve) => setTimeout(resolve, 500))

    return "Hello, " + result.data.name
  })

Then on the client side, you can use the useActionState hook to execute the action and handle the result.

use-action-state-skip-input-parsing.tsx
"use client"

import { useActionState } from "react";
import { produceNewMessage } from "./actions";

export default function UseActionStateSkipInputParsingExample() {
  let [[data, err], submitAction, isPending] = useActionState( 
    produceNewMessage, 
    [null, null] // or [initialData, null]
  ); 

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Skip Input Parsing</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form action={submitAction} className="flex flex-col gap-4">
          <Input name="name" placeholder="Enter your name..." />
          <Button disabled={isPending}>Create message</Button>
        </form>
        {isPending && <div>Loading...</div>}
        {data && <p>Message: {data}</p>}
        {err && <div>Error: {JSON.stringify(err)}</div>}
      </CardContent>
    </Card>
  );
}

Here is the result:

Skip Input Parsing

File Upload

ZSA supports file and/or image uploads within forms. To handle file uploads, you need to specify the expected file type in the server action using z.instanceof(File) and include a file input in your form.

actions.ts
"use server"

import { z } from "zod";
import { createServerAction } from "zsa";

export const uploadFile = createServerAction()
  .input(
    z.object({
      name: z.string().min(5),
      file: z 
        .instanceof(File) 
        .refine( 
          (file) => file.size > 0 && file.size < 1024, 
          "File size must be less than 1kb"
        ), 
    }),
    {
      type: "state",
    }
  )
  .handler(async ({ input }) => {
    // Process the uploaded file
    const { name, file } = input;
    // ...

    return "File uploaded successfully!";
  });

On the client side, include a file input in your form:

file-upload-example.tsx
"use client"

import { useActionState } from "zsa-react";
import { uploadFile } from "./actions";

export default function FileUploadExample() {
  const [[data, error], submitAction, isPending] = useActionState(uploadFile, [null, null]); 

  return (
    <form action={submitAction} className="flex flex-col gap-4">
      <label>
        Name:
        <input type="text" name="name" required />
      </label>
      <label>
        File:
        <input type="file" name="file" required />
      </label>
      <button type="submit" disabled={isPending}>
        Upload
      </button>
      {isPending && <div>Uploading...</div>}
      {data && <div>Success: {data}</div>}
      {error && <div>Error: {JSON.stringify(error.fieldErrors)}</div>}
    </form>
  );
}

In this example, we define a server action uploadFile that expects a name field of type string and a file field of type File. The type is set to "formData" to handle the form data.

On the client side, we include a file input with the name "file" in the form. When the form is submitted, the file is included in the FormData object and sent to the server action for processing.

Remember to handle the uploaded file appropriately in the server action's handler function based on your specific requirements.

That's it! You can now upload files using ZSA with forms.

Multi Entry Forms

ZSA also supports multi entry forms. This means that you can have multiple input fields with the same name in a form. To handle multi entry forms, you can set the schema to an array of the expected type.

Here's an example of how to create a multi entry form using ZSA:

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const multiplyNumbersAction = createServerAction()
  .input(
    z.object({
      // an array of numbers
      number: z.array(z.coerce.number()), 
      // an array of files
      filefield: z 
        .array( 
          z 
            .instanceof(File) 
            .refine( 
              (file) => file.size > 0, 
              "File cannot be empty"
            ) 
            .refine( 
              (file) => file.size < 1024, 
              "File size must be less than 1kb"
            ) 
        ) 
    }),
    {
      type: "state",
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return (
      input.number.reduce((a, b) => a * b, 1) +
      ` and got ${input.filefield.length} files`
    )
  })

Then on the client side, you can use the useActionState hook to handle the multi entry form:

multi-entry-example.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useActionState } from "react"
import { multiplyNumbersAction } from "./actions"

export default function MultiEntryExample() {
  let [[data, err], submitAction, isPending] = useActionState(
    multiplyNumbersAction,
    [null, null]
  )

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Multi Entry Form</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form action={submitAction} className="flex flex-col gap-4">
          <Input name="number" placeholder="Enter number..." type="number" />
          <Input name="number" placeholder="Enter number..." type="number" />
          <Input name="filefield" type="file" multiple />
          <Button disabled={isPending}>Multiply Numbers</Button>
        </form>
        {isPending && <div>Loading...</div>}
        {data && <p>Result: {data}</p>}
        {err && <div>Error: {JSON.stringify(err.fieldErrors)}</div>}
      </CardContent>
    </Card>
  )
}

Here is the result:

Multi Entry Form

Binding arguments

Sometimes you may want to bind arguments to the server action. This can be useful for passing additional data to the server action that won't be in your form. You can do this by passing an object to the bind option in the useServerAction hook.

actions.ts
"use server"

import z from "zod"
import { createServerAction } from "zsa"

export const produceNewMessage = createServerAction()
  .input(
    z.object({
      name: z.string().min(5),
      otherArg1: z.string(), 
    }),
    {
      type: "formData",
    }
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name + " " + input.otherArg1
  })

Then on the client side, you can use the useServerAction hook to execute the server action and handle the result.

bind-form-example.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useServerAction } from "zsa-react"
import { produceNewMessage } from "./actions"

export const BindFormExample = () => {
  const { isPending, executeFormAction, isSuccess, data, isError, error } =
    useServerAction(produceNewMessage, {
      bind: { 
        otherArg1: "binded", 
      }, 
    })

  return (
    <Card className="not-prose">
      <CardHeader>
        <CardTitle>Form Example</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <form className="flex flex-col gap-4" action={executeFormAction}>
          <Input type="text" name="name" placeholder="Enter your name..." />
          <Button className="w-full" type="submit" disabled={isPending}>
            Submit
          </Button>
          {isPending && <div>Loading...</div>}
          {isSuccess && <div>Success: {JSON.stringify(data)}</div>}
          {isError && <div>Error: {JSON.stringify(error.fieldErrors)}</div>}
        </form>
      </CardContent>
    </Card>
  )
}

Here is the result:

Form Example

On Submit

If the action is a form action, you can also send more fields in the second argument of the execute function. This is useful for sending additional fields that are not part of the form.

bind-form-on-submit.tsx
const { execute } = useServerAction(produceNewMessage)

return (
  <form
    onSubmit={async (event) => {
      event.preventDefault()
      const formData = new FormData(event.currentTarget)
      const [data, err] = await execute(formData, {
        otherArg1: "binded", 
      })

      if (err) {
        // handle error
        return
      }
    }}
  />
)