# Forms (https://v2.s3.dimah.dev/docs/react/forms)

Keep upload state and form state apart:

- **`useUpload`** — owns the browser `File`s, validation, progress, retries, and confirmation.
- **The form** — owns a value it can submit, usually the confirmed object keys.

A key belongs in the form once its file is confirmed. At that point the server
has verified the object with `HeadObject`.

## Upload flow [#upload-flow]

<Flow
  label="Upload field lifecycle"
  steps={[
    { name: "Select files", kind: "client", note: "UploadDropzone" },
    { name: "Presign", kind: "server", note: "guards and server-owned key" },
    { name: "Upload bytes", kind: "s3", note: "direct browser transfer" },
    { name: "Confirm", kind: "server", note: "HeadObject verification" },
    { name: "onSuccess", kind: "hook", note: "one confirmed UploadResult" },
    {
      name: "Set field value",
      kind: "client",
      note: "serializable object keys",
    },
  ]}
/>

`useUpload` stays the source of truth for progress and file UI. The form only
holds the value your app submits.

If some files of a call fail, the ones that uploaded stay confirmed and their
keys stay in the form. Let the user retry the failed files, or delete the
uploaded objects when the whole form is discarded.

---

## UploadField [#uploadfield]

[`UploadField`](https://v2.s3.dimah.dev/docs/react/ui/components/upload-field) from `@dimah-s3/ui`
is a form field whose value is the keys of the files uploaded through it.
Each key renders as a hidden `<input name>`, so it submits with a plain
`<form>`:

```tsx title="components/attachment-form.tsx"
"use client";

import { useUpload } from "@dimah-s3/react";
import { UploadDropzone, UploadField, UploadFieldValue } from "@dimah-s3/ui";

export function AttachmentForm({
  action,
}: {
  action: (data: FormData) => void;
}) {
  const upload = useUpload({ route: "attachments", maxFiles: 5 });

  return (
    <form action={action}>
      <UploadField upload={upload} name="attachments" maxFiles={5}>
        <UploadDropzone />
        <UploadFieldValue />
      </UploadField>
      <button type="submit" disabled={upload.isPending}>
        Save
      </button>
    </form>
  );
}
```

`maxFiles` on the field caps the keys it holds across calls. `maxFiles` on
the hook caps the files of one call. Read the keys on the server with
`formData.getAll("attachments")`.

Pass `value` and `onValueChange` to control the field from a form library.

---

## React Hook Form [#react-hook-form]

```tsx title="components/attachment-form.tsx"
"use client";

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useUpload } from "@dimah-s3/react";
import { UploadDropzone, UploadField, UploadFieldValue } from "@dimah-s3/ui";

const formSchema = z.object({
  title: z.string().min(1, "Title is required."),
  objectKeys: z.array(z.string()).min(1, "Upload at least one file."),
});

type FormValues = z.infer<typeof formSchema>;

export function AttachmentForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { title: "", objectKeys: [] },
  });
  const upload = useUpload({ route: "attachments", maxFiles: 5 });

  const reset = () => {
    form.reset();
    upload.reset();
  };

  return (
    <form
      aria-busy={upload.isPending}
      onSubmit={form.handleSubmit((values) => {
        console.log(values);
      })}
    >
      <Controller
        name="objectKeys"
        control={form.control}
        render={({ field, fieldState }) => (
          <div>
            <UploadField
              upload={upload}
              name={field.name}
              value={field.value}
              onValueChange={field.onChange}
              maxFiles={5}
            >
              <UploadDropzone />
              <UploadFieldValue />
            </UploadField>
            {fieldState.error ? (
              <p role="alert">{fieldState.error.message}</p>
            ) : null}
          </div>
        )}
      />

      <button type="submit" disabled={upload.isPending}>
        Submit
      </button>
      <button type="button" onClick={reset}>
        Reset
      </button>
    </form>
  );
}
```

The dropzone shows progress and per-file errors from `upload` inside it, so
none of it is copied into the form.

---

## TanStack Form [#tanstack-form]

```tsx title="components/attachment-form.tsx"
"use client";

import { useForm } from "@tanstack/react-form";
import { useUpload } from "@dimah-s3/react";
import { UploadDropzone, UploadField, UploadFieldValue } from "@dimah-s3/ui";

export function AttachmentForm() {
  const form = useForm({
    defaultValues: { objectKeys: [] as string[] },
    onSubmit: ({ value }) => {
      console.log(value);
    },
  });
  const upload = useUpload({ route: "attachments", maxFiles: 5 });

  return (
    <form
      aria-busy={upload.isPending}
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        void form.handleSubmit();
      }}
    >
      <form.Field
        name="objectKeys"
        validators={{
          onSubmit: ({ value }) =>
            value.length > 0 ? undefined : "Upload at least one file.",
        }}
      >
        {(field) => (
          <div>
            <UploadField
              upload={upload}
              name={field.name}
              value={field.state.value}
              onValueChange={field.handleChange}
              maxFiles={5}
            >
              <UploadDropzone />
              <UploadFieldValue />
            </UploadField>
            {field.state.meta.errors[0] ? (
              <p role="alert">{field.state.meta.errors[0]}</p>
            ) : null}
          </div>
        )}
      </form.Field>

      <button type="submit" disabled={upload.isPending}>
        Submit
      </button>
    </form>
  );
}
```

---

## Without UploadField [#without-uploadfield]

With your own controls, write each key from `onSuccess`. It runs once per
uploaded file, so a partly failed call still keeps its uploaded keys:

```ts
const upload = useUpload({
  route: "attachments",
  maxFiles: 5,
  onSuccess: (result) => {
    const keys = form.getValues("objectKeys");
    form.setValue("objectKeys", [...keys, result.key], {
      shouldDirty: true,
      shouldValidate: true,
    });
  },
  onError: (error) => {
    form.setError("objectKeys", {
      type: "upload",
      message: formatError(error),
    });
  },
});
```

For a single file, such as an avatar, store one nullable key:

```ts
const upload = useUpload({
  route: "avatar",
  onSuccess: (result) => {
    form.setValue("avatarKey", result.key);
  },
});
```

If the submission also needs the verified size, type, or name, store
`UploadResult[]` instead of keys:

```ts
import type { UploadResult } from "@dimah-s3/react";

type FormValues = {
  attachments: UploadResult[];
};
```

Do not put `File` objects in a value you send to your server. They are
browser-only and do not mean the upload finished.

---

## Rules [#rules]

1. Disable submit while `upload.isPending` is `true`.
2. Set values from confirmed results only: `UploadField`, or `onSuccess`.
3. Show `onError` or `upload.error` next to the field, even when you also toast.
4. Call both `form.reset()` and `upload.reset()` when the form resets.
5. Render progress, previews, and status from `upload.files`, not from form state.

<Callout>
  `maxFiles` is a client-side limit. Server routes enforce each file's type and
  size, but not how many keys a form submits. Check the count in the endpoint
  that receives the form.
</Callout>

---

## Frequently asked questions [#frequently-asked-questions]

<Accordions>

<Accordion title="Can I use a button instead of a dropzone?">

Yes. Render `UploadTrigger` inside `UploadField` in place of `UploadDropzone`:

```tsx
<UploadField upload={upload} name="attachments">
  <UploadTrigger>Attach files</UploadTrigger>
  <UploadStatus />
  <UploadFieldValue />
</UploadField>
```

</Accordion>

<Accordion title="Can I wait until submit to upload?">

Yes, with your own file picker. Keep the selected `File`s in local state, then
call `upload.handleFiles(files)` from your submit handler. It resolves with
the uploaded results once every file has ended.

The built-in components upload as soon as files are picked. That is the
simpler default, because the form already has confirmed keys when it
validates and submits.

</Accordion>

<Accordion title="What happens if the user abandons the form?">

A confirmed upload stays in S3 even if the form is never submitted. Delete it
when the user removes the file or resets the form, or track draft uploads in
your app and clean them up on a schedule.

With [`@dimah-s3/db`](https://v2.s3.dimah.dev/docs/db/purge), `s3.db.purgeStale()` only removes
uploads that were never confirmed. It does not remove confirmed objects that
nothing references.

</Accordion>

</Accordions>
