Forms

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>
  )
}
app/posts/new/page.tsx
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-labelledby pointing at the field label.
  • Set aria-invalid on the dropzone and data-invalid on the Field when there's an error.
  • Connect the description and error with aria-describedby.

UploadField does all of this for you.

On this page