File & Hidden Fields
The file dropzone field and the label-less hidden field.
Two field types sit at opposite ends of the visibility spectrum. The file field
is a full drag-and-drop dropzone with a selected-file list and inline rejection
messages. The hidden field renders nothing the user can interact with — it just
carries a value through form state and submission.
Both are built-in field types, registered under the file and hidden keys in the
form builder's renderer registry, so you reach them the same way as any other field:
by setting type in your FormFields config.
File field
The file field renders a react-dropzone-backed drop area. The stored value is a
raw File (single mode) or File[] (multi mode) — the builder hands you the actual
browser File objects and stops there.
The form builder does not own the upload pipeline. There is no blob URL
creation, no "upload then commit a URL" step, no progress bar. Your submit handler
receives the File objects and decides what to do with them — POST to your upload
endpoint, attach to a multipart request, etc. This keeps the field agnostic about
your storage backend.
Config
The file field extends FieldConfigBase (so it inherits label, description,
required, disabled, visible, colSpan, validate, and validateAsync) and
adds these file-specific keys:
| Key | Type | Description |
|---|---|---|
accept | string | Comma-separated MIME types and/or .ext extensions, e.g. "image/jpeg,image/png". Follows react-dropzone semantics — see the warning below. Omit to accept anything. |
multiple | boolean | When true, the field accepts multiple files and stores File[]. Defaults to false (stores a single File). |
maxSize | number | Maximum size of any one file, in bytes. |
maxFiles | number | Maximum number of files. Only meaningful when multiple is true. |
required on a file field is a UI hint only — it renders the * indicator and sets
aria-required, but it does not enforce that a file was chosen. To make an upload
mandatory, drop .optional() from the schema branch (e.g. z.instanceof(File) instead
of z.instanceof(File).optional()).
Single vs. multi mode
The mode you pick changes both the stored shape and the drop behavior:
| Mode | Stored value | Drop behavior |
|---|---|---|
multiple: false (default) | File | undefined | A new drop replaces the current selection. |
multiple: true | File[] | New drops are appended to the existing array, deduplicated by name + size, then sliced to maxFiles. |
Each selected file appears in a list below the drop area with its name, a formatted size, and a remove button.
The accept string follows react-dropzone rules
The accept string is parsed into react-dropzone's Accept object before reaching
the dropzone. Pure MIME lists and pure extension lists behave intuitively, but mixing
the two is a trap.
Do not mix MIME types and bare extensions to mean "OR". When both are present, the
extensions are attached to the first MIME as a narrowing filter. So
accept="image/*,.pdf" means "image MIMEs whose filename ends in .pdf" — which
matches essentially nothing.
For a true "images OR PDFs", spell out both as MIME types:
accept="image/*,application/pdf".
Safe patterns:
- Pure MIME list:
"image/jpeg,image/png"— accepts JPEG or PNG. - Pure extension list:
".png,.jpg"— accepts those extensions (falls back to a*/*wildcard internally). - Mixed: only use when you genuinely want the narrowing behavior.
Per-file rejections
When react-dropzone rejects a dropped file (wrong type, over maxSize, over
maxFiles), the field lists each rejected file's name and reason below the drop area.
Rejection messages render in an element with role="status" and aria-live="polite",
so a screen reader announces them politely as advisory notices. This is deliberately
distinct from the field-level role="alert" element that the form builder uses for
Zod validation errors — mounting two competing live regions would produce ambiguous
announcements. Format errors (from accept) appear as polite rejections; schema-level
errors appear as alerts.
Example
A single-file image picker and a multi-file document picker, side by side. Note the
schema uses z.instanceof(File) (and z.array(z.instanceof(File)) for multi mode),
and the submit handler maps to metadata because raw File objects do not serialize to
JSON.
import {z} from "zod/v4";
import {useFormBuilder} from "@/components/forms";
import type {FormFields} from "@/components/forms";
const schema = z.object({
avatar: z.instanceof(File).optional(),
documents: z.array(z.instanceof(File)).optional(),
});
const fields: FormFields<typeof schema> = {
avatar: {
type: "file",
label: "Profile photo",
description: "Single image, max 2 MB. JPEG / PNG only.",
accept: "image/jpeg,image/png",
maxSize: 2 * 1024 * 1024,
colSpan: {base: 12, md: 6},
},
documents: {
type: "file",
label: "Attachments",
description: "Up to 5 PDF or Word documents; each ≤ 10 MB.",
accept:
"application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document",
multiple: true,
maxFiles: 5,
maxSize: 10 * 1024 * 1024,
colSpan: {base: 12, md: 6},
},
};
function ProfileForm() {
const form = useFormBuilder({
formId: "profile",
schema,
fields,
onSubmit: (values) => {
uploadAvatar(values.avatar);
uploadDocuments(values.documents);
},
});
return (
<form.Provider>
<form.Fields names={["avatar", "documents"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>
);
}Because the stored value is a live File, anything you log or persist as JSON should
serialize the metadata you care about first (e.g. {name, size, type}). The File
itself is the thing you stream to your upload endpoint.
Hidden field
The hidden field carries a value through form state and submission without rendering
any visible affordance. It renders a bare <input type="hidden"> whose value mirrors
the field state, mainly for parity with native form behavior (browser autofill
heuristics, devtools visibility) — the authoritative value always lives in the form
store regardless of what's painted to the DOM.
Config
The hidden field is the one built-in that does not extend FieldConfigBase. Its
full config shape is just:
{type: "hidden"; label?: never}That label?: never makes writing label: "anything" a compile error — hidden fields
have no label by definition.
Because it skips FieldConfigBase, a hidden field has no description,
placeholder, disabled, visible, colSpan, validate, or validateAsync. It
also skips the column-span grid wrapper entirely — the renderer reserves no grid space
and emits only the <input>.
You cannot attach a field-level validator or a visibility predicate to a hidden field.
All validation for a hidden field must live in the Zod schema. It is still validated
on submit like any other field, but there is no visible error affordance — if a hidden
field fails validation, the error surfaces only through
form.useFormState((s) => s.errors), and the Submit button can appear inert with no
on-screen explanation. Make sure the schema branch for a hidden field can actually
validate the value you store in it.
Value coercion
The rendered DOM attribute is coerced with String(value), so numbers and booleans
produce well-formed attributes. undefined and null render as an empty string. If
you need to carry a structured payload, serialize it (e.g. JSON.stringify) before
storing it in the field, and parse it back in your submit handler.
Example
A common use is threading a record's identity or a version stamp through an edit form without showing it to the user.
import {z} from "zod/v4";
import {useFormBuilder} from "@/components/forms";
import type {FormFields} from "@/components/forms";
const schema = z.object({
name: z.string().min(1),
ownerId: z.string().uuid(),
version: z.number().int(),
});
const fields: FormFields<typeof schema> = {
name: {type: "text", label: "Name"},
ownerId: {type: "hidden"},
version: {type: "hidden"},
};
function EditForm({record}: {record: {ownerId: string; version: number}}) {
const form = useFormBuilder({
formId: "edit-record",
schema,
fields,
defaultValues: {ownerId: record.ownerId, version: record.version},
onSubmit: (values) => save(values),
});
return (
<form.Provider>
<form.Fields names={["name", "ownerId", "version"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>
);
}Since the hidden fields validate on submit, give them sensible defaultValues (or
populate them on load). A z.string().uuid() hidden field with no value will block
submission silently — the form is invalid but nothing on screen explains why.