2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

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 browser Files, 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 field lifecycle
  1. Select filesUploadDropzone
  2. Presignguards and server-owned key
  3. Upload bytesdirect browser transfer
  4. ConfirmHeadObject verification
  5. onSuccessone confirmed UploadResult
  6. 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>:

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

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

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

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

  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.

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

On this page