Forms
Store confirmed S3 object keys in a form, with UploadField, React Hook Form, or TanStack Form.
Keep upload state and form state apart:
useUpload— owns the browserFiles, 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
- Select filesUploadDropzone
- Presignguards and server-owned key
- Upload bytesdirect browser transfer
- ConfirmHeadObject verification
- onSuccessone confirmed UploadResult
- Set field valueserializable 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 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>:
"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
"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
"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
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:
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:
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:
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
- Disable submit while
upload.isPendingistrue. - Set values from confirmed results only:
UploadField, oronSuccess. - Show
onErrororupload.errornext to the field, even when you also toast. - Call both
form.reset()andupload.reset()when the form resets. - Render progress, previews, and status from
upload.files, not from form state.
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.
Frequently asked questions
Yes. Render UploadTrigger inside UploadField in place of UploadDropzone:
<UploadField upload={upload} name="attachments">
<UploadTrigger>Attach files</UploadTrigger>
<UploadStatus />
<UploadFieldValue />
</UploadField>Yes, with your own file picker. Keep the selected Files 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.
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, s3.db.purgeStale() only removes
uploads that were never confirmed. It does not remove confirmed objects that
nothing references.