Better Upload
Forms

React Hook Form

It's common for file uploads to be part of a form. In this guide, we'll take a look at how to add file uploads to a form built with React Hook Form. This guide is based on the shadcn/ui form guide and will use the <Field /> component.

We'll cover multiple file uploads, but the same principles apply for single file uploads.

Form

Installation

Install the following shadcn/ui components. Make sure to also have React Hook Form and Zod installed.

npx shadcn@latest add field input button

Setup upload route

Setup your upload route. Use your preferred framework, but for this example, we'll use Next.js.

app/api/upload/route.ts
import { route, type Router } from '@better-upload/server';
import { toRouteHandler } from '@better-upload/server/adapters/next';
import { aws } from '@better-upload/server/clients';

const router: Router = {
  client: aws(),
  bucketName: 'my-bucket',
  routes: {
    form: route({
      multipleFiles: true,
      maxFiles: 5,
      maxFileSize: 1024 * 1024 * 5, // 5MB
      onBeforeUpload() {
        return {
          generateObjectInfo: ({ file }) => ({ key: `form/${file.name}` }),
        };
      },
    }),
  },
};

export const { POST } = toRouteHandler(router);

Define form schema

We'll start by defining the shape of our form using a Zod schema. It contains two fields:

  • folderName: For an arbitrary text input.
  • objectKeys: For the uploaded files, stores the S3 object keys.
form.tsx
import * as z from 'zod';

const formSchema = z.object({
  folderName: z.string().min(1, 'Folder name is required.'),
  objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'),
});

Setup the form

Next, we'll use the useForm hook from React Hook Form to create our form instance, along with the useUploadFiles hook to handle file uploads.

form.tsx
import { useUploadFiles } from '@better-upload/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';

const formSchema = z.object({
  folderName: z.string().min(1, 'Folder name is required.'),
  objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'),
});

export function FormUploader() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      folderName: '',
      objectKeys: [],
    },
  });

  const uploader = useUploadFiles({
    route: 'form',
    onUploadComplete: ({ files }) => {
      form.setValue(
        'objectKeys',
        files.map((file) => file.objectInfo.key)
      );
    },
    onError: (error) => {
      form.setError('objectKeys', {
        message: error.message || 'An error occurred.',
      });
    },
  });

  function onSubmit(data: z.infer<typeof formSchema>) {
    // call your API here
    console.log(data);
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* ... */}
      {/* Build the form here */}
      {/* ... */}
    </form>
  );
}

Build the form

We can now build the form using the <Controller /> component from React Hook Form and the <UploadDropzone /> component for the file uploads.

form.tsx
'use client';

import { useUploadFiles } from '@better-upload/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import * as z from 'zod';

import { Button } from '@/components/ui/button';
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { UploadDropzone } from '@/components/ui/upload-dropzone';

const formSchema = z.object({
  folderName: z.string().min(1, 'Folder name is required.'),
  objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'),
});

export function FormUploader() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      folderName: '',
      objectKeys: [],
    },
  });

  const uploader = useUploadFiles({
    route: 'form',
    onUploadComplete: ({ files }) => {
      form.setValue(
        'objectKeys',
        files.map((file) => file.objectInfo.key)
      );
    },
    onError: (error) => {
      form.setError('objectKeys', {
        message: error.message || 'An error occurred.',
      });
    },
  });

  function onSubmit(data: z.infer<typeof formSchema>) {
    // call your API here
    console.log(data);
  }

  return (
    <Card className="w-full sm:max-w-md">
      <CardHeader>
        <CardTitle>Form Uploader</CardTitle>
        <CardDescription>Upload files to a specific folder.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-demo" onSubmit={form.handleSubmit(onSubmit)}>
          <FieldGroup>
            <Controller
              name="folderName"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="form-rhf-demo-folderName">
                    Folder name
                  </FieldLabel>
                  <Input
                    {...field}
                    id="form-rhf-demo-folderName"
                    aria-invalid={fieldState.invalid}
                    placeholder="my-folder"
                    autoComplete="off"
                  />
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
            <Controller
              name="objectKeys"
              control={form.control}
              render={({ fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="form-rhf-demo-objectKeys">
                    Files
                  </FieldLabel>
                  <UploadDropzone
                    id="form-rhf-demo-objectKeys"
                    control={uploader.control}
                    description={{
                      maxFiles: 5,
                      maxFileSize: '5MB',
                    }}
                  />
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>
        </form>
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset();
              uploader.reset();
            }}
          >
            Reset
          </Button>
          <Button
            type="submit"
            form="form-rhf-demo"
            disabled={uploader.isPending}
          >
            Submit
          </Button>
        </Field>
      </CardFooter>
    </Card>
  );
}

Upload on form submit

In this example, we only upload the files after the user clicks on the submit button. We'll use the uploadOverride prop to override the default behavior of the <UploadDropzone />.

form.tsx
'use client';

import { useUploadFiles } from '@better-upload/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import * as z from 'zod';

import { Button } from '@/components/ui/button';
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { UploadDropzone } from '@/components/ui/upload-dropzone';

const formSchema = z.object({
  folderName: z.string().min(1, 'Folder name is required.'),
  files: z.array(z.file()).min(1, 'Upload at least one file.'),
  // for Zod v3: z.array(z.instanceof(File)).min(1, 'Upload at least one file.'),
});

export function FormUploader() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      folderName: '',
      files: [],
    },
  });

  const uploader = useUploadFiles({
    route: 'form',
    onError: (error) => {
      form.setError('files', {
        message: error.message || 'An error occurred.',
      });
    },
  });

  async function onSubmit(data: z.infer<typeof formSchema>) {
    const { files } = await uploader.upload(data.files);

    // call your API here
    console.log({
      folderName: data.folderName,
      objectKeys: files.map((file) => file.objectInfo.key),
    });
  }

  return (
    <Card className="w-full sm:max-w-md">
      <CardHeader>
        <CardTitle>Form Uploader</CardTitle>
        <CardDescription>Upload files to a specific folder.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-demo" onSubmit={form.handleSubmit(onSubmit)}>
          <FieldGroup>
            <Controller
              name="folderName"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="form-rhf-demo-folderName">
                    Folder name
                  </FieldLabel>
                  <Input
                    {...field}
                    id="form-rhf-demo-folderName"
                    aria-invalid={fieldState.invalid}
                    placeholder="my-folder"
                    autoComplete="off"
                  />
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
            <Controller
              name="files"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="form-rhf-demo-files">Files</FieldLabel>
                  {field.value.length > 0 ? (
                    <div className="flex flex-col">
                      {field.value.map((file) => (
                        <span key={file.name} className="text-sm">
                          {file.name}
                        </span>
                      ))}
                    </div>
                  ) : (
                    <UploadDropzone
                      id="form-rhf-demo-files"
                      control={uploader.control}
                      description={{
                        maxFiles: 5,
                        maxFileSize: '5MB',
                      }}
                      uploadOverride={(files) => {
                        field.onChange(Array.from(files));
                      }}
                    />
                  )}
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>
        </form>
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset();
              uploader.reset();
            }}
          >
            Reset
          </Button>
          <Button
            type="submit"
            form="form-rhf-demo"
            disabled={uploader.isPending}
          >
            Submit
          </Button>
        </Field>
      </CardFooter>
    </Card>
  );
}