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 buttonSetup upload route
Setup your upload route. Use your preferred framework, but for this example, we'll use Next.js.
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.
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.
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.
'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 />.
'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>
);
}