react-hook-form, TanStack Form, native forms and server actions.
The pattern: upload while the user fills in the form, submit object keys. The form value is a list of strings, so validation is simple and submitting is instant.
react-hook-form
"use client"
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { UploadField } from "@/components/upload-field"
const schema = z.object({
title: z.string().min(1, "Give your submission a title"),
attachments: z
.array(z.string())
.min(1, "Attach at least one file")
.max(3, "Attach at most 3 files"),
})
type Values = z.infer<typeof schema>
export default function FormUploadExample() {
const [isUploading, setIsUploading] = React.useState(false)
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { title: "", attachments: [] },
})
function onSubmit(values: Values) {
toast("Submitted", {
description: (
<pre className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(values, null, 2)}
</pre>
),
})
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<FieldGroup>
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<FieldLabel htmlFor="submission-title">Title</FieldLabel>
<Input
{...field}
id="submission-title"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="attachments"
control={form.control}
render={({ field, fieldState }) => (
<UploadField
label="Attachments"
description="Up to 3 files. They upload while you fill in the form."
required
maxFiles={3}
error={fieldState.error?.message}
onChange={field.onChange}
onBlur={field.onBlur}
onUploadingChange={setIsUploading}
/>
)}
/>
</FieldGroup>
<Button type="submit" disabled={isUploading} className="self-start">
{isUploading ? "Waiting for uploads…" : "Submit"}
</Button>
</form>
)
}
UploadField is controlled: onChange receives the keys of successful uploads, and
onUploadingChange lets you disable submit while files are still uploading.
TanStack Form
<form.Field
name="attachments"
validators={{ onChange: ({ value }) => (value.length ? undefined : "Attach a file") }}
>
{(field) => (
<UploadField
label="Attachments"
error={field.state.meta.errors[0]}
onChange={field.handleChange}
onBlur={field.handleBlur}
/>
)}
</form.Field>Native forms and server actions
Give Upload a name; each successful upload renders a hidden input.
"use client"
import * as React from "react"
import { s3Adapter, validateFileCount } from "@uploadcn/core"
import { useUploadValue, useUploader } from "@uploadcn/react"
import { demoTransport } from "@/examples/_demo"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import {
Upload,
UploadDropzone,
UploadDropzoneDescription,
UploadDropzoneTitle,
UploadQueue,
} from "@/components/ui/upload"
const adapter = s3Adapter({
endpoint: "/api/upload",
transport: demoTransport, // docs only, see examples/_demo.ts
})
const getKey = (item: { result?: { key: string } }) => item.result?.key ?? ""
/**
* A plain <form>: successful uploads are submitted as hidden inputs named
* "documents", so this works with server actions too.
*/
export default function RequiredUploadExample() {
const uploader = useUploader({ adapter, maxFiles: 2 })
const keys = useUploadValue(uploader, getKey)
const [error, setError] = React.useState<string | null>(null)
const [submitted, setSubmitted] = React.useState<string[] | null>(null)
const labelId = React.useId()
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const issue = validateFileCount(keys.length, { minFiles: 1, maxFiles: 2 })
setError(issue?.message ?? null)
if (issue) return
setSubmitted(
new FormData(event.currentTarget).getAll("documents").map(String)
)
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<Field data-invalid={error ? true : undefined}>
<FieldLabel id={labelId}>
Proof of identity{" "}
<span aria-hidden className="text-muted-foreground">
*
</span>
</FieldLabel>
<Upload uploader={uploader} name="documents">
<UploadDropzone
aria-labelledby={labelId}
aria-required
aria-invalid={error ? true : undefined}
className="py-6 aria-invalid:border-destructive"
>
<UploadDropzoneTitle>Add 1–2 documents</UploadDropzoneTitle>
<UploadDropzoneDescription>
Passport, ID card or driver’s license
</UploadDropzoneDescription>
</UploadDropzone>
<UploadQueue />
</Upload>
<FieldDescription>
Required. Files are encrypted at rest.
</FieldDescription>
{error ? <FieldError>{error}</FieldError> : null}
</Field>
<Button type="submit" className="self-start">
Continue
</Button>
{submitted ? (
<p className="text-sm text-muted-foreground">
Submitted keys:{" "}
<code className="text-xs">{submitted.join(", ")}</code>
</p>
) : null}
</form>
)
}
async function createPost(formData: FormData) {
"use server"
const images = formData.getAll("images") // object keys
await db.post.create({ data: { images } })
}
<form action={createPost}>
<Upload adapter={adapter} name="images">…</Upload>
<button type="submit">Publish</button>
</form>Accessibility in forms
- Label the dropzone with
aria-labelledbypointing at the field label. - Set
aria-invalidon the dropzone anddata-invalidon theFieldwhen there's an error. - Connect the description and error with
aria-describedby.
UploadField does all of this for you.