Forms
Integrate confirmed S3 uploads with React Hook Form or TanStack Form.
Keep upload state and form state separate:
useUploadowns browserFileobjects, validation, progress, retries, and confirmation.- The form owns a serializable value—usually the confirmed S3 object keys.
Only write a key into the form after onSuccess runs. At that point the server
has accepted the key and verified the uploaded object with HeadObject.
Upload flow
- Select filesUploadDropzone
- Presignguards and server-owned key
- Upload bytesdirect browser transfer
- ConfirmHeadObject verification
- onSuccessconfirmed UploadResult[]
- Set field valueserializable object keys
useUpload remains the source of truth for progress and file UI. The form only
stores the value that your application submits.
Choose a field value
For most forms, store object keys:
type FormValues = {
objectKeys: string[];
};Map confirmed results to that value in onSuccess:
onSuccess: (results) => {
form.setValue(
"objectKeys",
results.map((result) => result.key),
);
};If the submission also needs verified size, content type, or filename, store
UploadResult[] instead:
import type { UploadResult } from "@dimah-s3/react";
type FormValues = {
attachments: UploadResult[];
};Do not store File[] in a payload that will be sent to your server. File
objects are browser-only and do not represent a completed upload.
React Hook Form
This example uploads immediately after selection and stores the confirmed keys in React Hook Form.
"use client";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useFormatDimahError, useUpload } from "@dimah-s3/react";
import { UploadDropzone } 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 formatError = useFormatDimahError();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
objectKeys: [],
},
});
const upload = useUpload({
route: "attachments",
maxFiles: 5,
onUploadStart: () => {
form.clearErrors("objectKeys");
},
onSuccess: (results) => {
form.setValue(
"objectKeys",
results.map((result) => result.key),
{
shouldDirty: true,
shouldValidate: true,
},
);
},
onError: (error) => {
form.setError("objectKeys", {
type: "upload",
message: formatError(error),
});
},
});
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={({ fieldState }) => (
<div>
<span>Files</span>
<UploadDropzone upload={upload} />
{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 Controller registers the upload field and exposes its validation state.
UploadDropzone still renders from upload, so progress and per-file status do
not need to be copied into React Hook Form.
TanStack Form
The same boundary applies to TanStack Form: write confirmed keys with
setFieldValue, and render transfer errors from upload.error.
"use client";
import { useForm } from "@tanstack/react-form";
import { useFormatDimahError, useUpload } from "@dimah-s3/react";
import { UploadDropzone } from "@dimah-s3/ui";
export function AttachmentForm() {
const formatError = useFormatDimahError();
const form = useForm({
defaultValues: {
objectKeys: [] as string[],
},
onSubmit: ({ value }) => {
console.log(value);
},
});
const upload = useUpload({
route: "attachments",
maxFiles: 5,
onSuccess: (results) => {
form.setFieldValue(
"objectKeys",
results.map((result) => result.key),
);
},
});
const reset = () => {
form.reset();
upload.reset();
};
return (
<form
aria-busy={upload.isPending}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
}}
>
<form.Field
name="objectKeys"
validators={{
onSubmit: ({ value }) =>
value.length > 0 ? undefined : "Upload at least one file.",
}}
>
{(field) => {
const error =
upload.error != null
? formatError(upload.error)
: field.state.meta.errors[0];
return (
<div>
<span>Files</span>
<UploadDropzone upload={upload} />
{error ? <p role="alert">{error}</p> : null}
</div>
);
}}
</form.Field>
<button type="submit" disabled={upload.isPending}>
Submit
</button>
<button type="button" onClick={reset}>
Reset
</button>
</form>
);
}You can use a Standard Schema validator such as Zod at the form level instead. That does not change the upload integration.
Single-file fields
For an avatar or cover image, store one nullable key and limit intake to one file:
const upload = useUpload({
route: "avatar",
maxFiles: 1,
onSuccess: (results) => {
form.setValue("avatarKey", results[0]?.key ?? null);
},
});Integration rules
- Disable submission while
upload.isPendingistrue. - Set the field value only from
onSuccess. - Surface
onErrororupload.errornext to the field, even when also using a toast. - Call both
form.reset()andupload.reset()when resetting the form. - Render progress, previews, and transfer status from
upload.files, not from duplicated form state.
maxFiles is a client-side intake limit on useUpload. Server routes enforce
each file's type and size, but they do not enforce a form-level attachment
count. Validate the submitted key count in your application endpoint.
Frequently asked questions
Yes. Both components accept the same UseUploadReturn, so the form wiring does
not change:
<UploadButton upload={upload} label="Attach files" />Yes, with a custom file picker. Keep selected File objects in local client
state, then pass them to upload.handleFiles() from your submit flow.
The built-in UploadButton and UploadDropzone use eager uploads by design.
Eager uploads are the simpler default because the form already has confirmed
keys when validation and submission run.
A successfully confirmed upload remains in S3 even if the form is never submitted. Delete it explicitly when the user removes or resets the field, or track draft attachments in your application and clean them up on a schedule.
purgeStalePendingObjects only cleans up uploads that never completed S3
confirmation. It does not remove successfully uploaded but unreferenced
objects.