Better Upload
Forms

TanStack 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 TanStack 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 TanStack 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

Use the useForm hook from TanStack Form to create your form instance with Zod validation, along with the useUploadFiles hook to handle file uploads.

form.tsx
import { useUploadFiles } from '@better-upload/client';
import { useForm } from '@tanstack/react-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({
    defaultValues: {
      folderName: '',
      objectKeys: [] as string[],
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: ({ value }) => {
      // call your API here
      console.log(value);
    },
  });

  const uploader = useUploadFiles({
    route: 'form',
    onUploadComplete: ({ files }) => {
      form.setFieldValue(
        'objectKeys',
        files.map((file) => file.objectInfo.key)
      );
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
    >
      {/* ... */}
    </form>
  );
}

Build the form

We can now build the form using the form.Field component from TanStack Form and the <UploadDropzone /> component for the file uploads.

form.tsx
'use client';

import { useUploadFiles } from '@better-upload/client';
import { useForm } from '@tanstack/react-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({
    defaultValues: {
      folderName: '',
      objectKeys: [] as string[],
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: ({ value }) => {
      // call your API here
      console.log(value);
    },
  });

  const uploader = useUploadFiles({
    route: 'form',
    onUploadComplete: ({ files }) => {
      form.setFieldValue(
        '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="uploader-form"
          onSubmit={(e) => {
            e.preventDefault();
            form.handleSubmit();
          }}
        >
          <FieldGroup>
            <form.Field
              name="folderName"
              children={(field) => {
                const isInvalid =
                  field.state.meta.isTouched && !field.state.meta.isValid;
                return (
                  <Field data-invalid={isInvalid}>
                    <FieldLabel htmlFor={field.name}>Folder name</FieldLabel>
                    <Input
                      id={field.name}
                      name={field.name}
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      aria-invalid={isInvalid}
                      placeholder="my-folder"
                      autoComplete="off"
                    />
                    {isInvalid && (
                      <FieldError errors={field.state.meta.errors} />
                    )}
                  </Field>
                );
              }}
            />
            <form.Field
              name="objectKeys"
              children={(field) => {
                const isInvalid =
                  (field.state.meta.isTouched && !field.state.meta.isValid) ||
                  uploader.isError;
                return (
                  <Field data-invalid={isInvalid}>
                    <FieldLabel htmlFor={field.name}>Folder name</FieldLabel>
                    <UploadDropzone
                      id={field.name}
                      control={uploader.control}
                      description={{
                        maxFiles: 5,
                        maxFileSize: '5MB',
                      }}
                    />
                    {isInvalid && (
                      <FieldError
                        errors={
                          uploader.error
                            ? [{ message: uploader.error.message }]
                            : field.state.meta.errors
                        }
                      />
                    )}
                  </Field>
                );
              }}
            />
          </FieldGroup>
        </form>
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset();
              uploader.reset();
            }}
          >
            Reset
          </Button>
          <Button
            type="submit"
            form="uploader-form"
            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 { useForm } from '@tanstack/react-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({
    defaultValues: {
      folderName: '',
      files: [] as File[],
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: async ({ value }) => {
      const { files } = await uploader.upload(value.files);

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

  const uploader = useUploadFiles({
    route: 'form',
  });

  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="uploader-form"
          onSubmit={(e) => {
            e.preventDefault();
            form.handleSubmit();
          }}
        >
          <FieldGroup>
            <form.Field
              name="folderName"
              children={(field) => {
                const isInvalid =
                  field.state.meta.isTouched && !field.state.meta.isValid;
                return (
                  <Field data-invalid={isInvalid}>
                    <FieldLabel htmlFor={field.name}>Folder name</FieldLabel>
                    <Input
                      id={field.name}
                      name={field.name}
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      aria-invalid={isInvalid}
                      placeholder="my-folder"
                      autoComplete="off"
                    />
                    {isInvalid && (
                      <FieldError errors={field.state.meta.errors} />
                    )}
                  </Field>
                );
              }}
            />
            <form.Field
              name="files"
              children={(field) => {
                const isInvalid =
                  (field.state.meta.isTouched && !field.state.meta.isValid) ||
                  uploader.isError;
                return (
                  <Field data-invalid={isInvalid}>
                    <FieldLabel htmlFor={field.name}>Folder name</FieldLabel>
                    {field.state.value.length > 0 ? (
                      <div className="flex flex-col">
                        {field.state.value.map((file) => (
                          <span key={file.name} className="text-sm">
                            {file.name}
                          </span>
                        ))}
                      </div>
                    ) : (
                      <UploadDropzone
                        id={field.name}
                        control={uploader.control}
                        description={{
                          maxFiles: 5,
                          maxFileSize: '5MB',
                        }}
                        uploadOverride={(files) => {
                          field.handleChange(Array.from(files));
                        }}
                      />
                    )}
                    {isInvalid && (
                      <FieldError
                        errors={
                          uploader.error
                            ? [{ message: uploader.error.message }]
                            : field.state.meta.errors
                        }
                      />
                    )}
                  </Field>
                );
              }}
            />
          </FieldGroup>
        </form>
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset();
              uploader.reset();
            }}
          >
            Reset
          </Button>
          <Button
            type="submit"
            form="uploader-form"
            disabled={uploader.isPending}
          >
            Submit
          </Button>
        </Field>
      </CardFooter>
    </Card>
  );
}