Without using any framework, how do you take over a form's submission and send it with fetch while sending exactly the data the browser would have sent, and what is the form's formdata event for?
answer
- read action and method off the form
- one constructor rebuilds the entry list
- pass the submitter as the second argument
- let the browser write the boundary
- a hook to append entries, not to cancel
basics
~20 sListen for the form's submit event, prevent the default navigation, and build new FormData(form, event.submitter) — that reproduces the browser's own entry list. The formdata event fires whenever that list is built, letting a handler append entries for state no control holds.
solid answer
~50 sKeep the markup honest first: write real `action`, `method` and `enctype` attributes so the form works with scripting unavailable, then read them back with `form.action` and `form.method` instead of hardcoding the URL. In a `submit` listener call `event.preventDefault()` and construct `new FormData(form, event.submitter)`; the second argument adds the activated button's `name`/`value`, which a bare `new FormData(form)` omits. Pass that object straight as a `fetch` body and the browser encodes it as `multipart/form-data` and writes the boundary — so never set `Content-Type` yourself. For a urlencoded body, wrap it in `new URLSearchParams(formData)`. Read repeated names with `getAll`, since flattening with `Object.fromEntries` keeps only the last value. The `formdata` event fires on the form whenever its entry list is constructed — for native submissions too — and its handler can mutate `event.formData`, so a custom control can contribute a value without a hidden input.
go deeper
Know that new FormData(form) collects the form's values the same way the browser does, and that it can be handed straight to fetch as the request body.
Explain why the submitter must be passed to the constructor, why the Content-Type header must be left to the browser, and when getAll is required instead of get.
Design the enhancement so the markup still submits without script, and own what preventDefault takes over: pending state, double-submit guard, error handling and announcing the result to assistive technology.
Decide the house pattern for form submission — where progressive enhancement is mandatory, how custom controls contribute entries rather than syncing hidden inputs, and what every enhanced form must do for keyboard and screen-reader users.
## Start from markup that already works A form that only functions once script has loaded and run is a form that is broken during the load, after a script error, and on a flaky connection. So write the real thing first: ```html <form id="signup" action="/api/signup" method="post"> <label for="email">Email</label> <input id="email" name="email" type="email"> <button name="intent" value="create">Create account</button> </form> ``` Then enhance it, reading the destination out of the markup rather than repeating it in JavaScript. `form.action` and `form.method` give you exactly what the browser would have used, so the two paths cannot drift apart. ## Reproducing the browser's entry list ```js const form = document.querySelector("#signup"); form.addEventListener("submit", async (event) => { event.preventDefault(); const data = new FormData(form, event.submitter); const response = await fetch(form.action, { method: form.method, body: data }); // handle response… }); ``` Three details carry most of the value here. **`new FormData(form)` walks the form's controls** using the same rules the browser uses natively: named, enabled controls only, unchecked boxes skipped, controls attached with the `form` attribute included. That is why it is the right way to collect values — it cannot drift from what a native submit would have produced. **The second argument is the submitter.** Without it, the activated button's `name`/`value` is missing, so a form with `intent=create` on the button silently stops sending `intent` the moment you enhance it. `event.submitter` is the element the browser recorded as triggering submission — the clicked button, or the default button when the user pressed Enter. **Do not set the `Content-Type` header.** Passing a `FormData` object as a `fetch` body makes the browser encode it as `multipart/form-data` and generate the boundary token that the receiver needs to split the parts. Writing the header yourself ships a content type with no boundary and the server cannot parse the body. If the endpoint expects `application/x-www-form-urlencoded`, convert instead: ```js const body = new URLSearchParams(data); // text entries only ``` URLSearchParams has nowhere to put a file, so this only applies to text forms. ## Reading values out of a FormData `get(name)` returns the first entry, `getAll(name)` returns every entry with that name, and `has`, `append`, `set` and `delete` do what they say — with the important distinction that `append` adds another entry while `set` replaces all existing ones. The convenient-looking `Object.fromEntries(data)` is a trap for checkbox groups and any repeated name: an object can hold one value per key, so all but the last are dropped. Use `getAll` for anything multi-valued. And if the endpoint wants JSON, remember that `JSON.stringify(formData)` produces `{}` — you have to build the object yourself. ## What the formdata event is for `formdata` fires **on the form** every time its entry list is constructed — during a native submission *and* when script calls `new FormData(form)`. The handler receives an event whose `formData` property is the list being built, and mutating it changes what gets submitted: ```js form.addEventListener("formdata", (event) => { event.formData.append("signature", canvas.toDataURL()); event.formData.append("clientTimezone", Intl.DateTimeFormat().resolvedOptions().timeZone); }); ``` The point is decoupling. A custom control — a drawing canvas, a rich text editor, a map picker — holds state that no form control owns. The old approach was to keep a hidden input in sync with it, which means the widget must know about the form's markup and every place that mutates the widget must remember to update the input. With `formdata`, the widget registers one listener and contributes its value at exactly the moment the value is needed, and it works whether the form is submitted natively or collected by script. The event is not cancelable — it is a hook for contributing entries, not a validation gate, and it is not the place to block a submission. ## What you inherit responsibility for Once you call `preventDefault`, the browser stops doing several things it used to do for you: showing progress, disabling nothing while the request is in flight, and navigating on success. You now own the pending state, the double-submit guard, the error path when `fetch` rejects, and telling assistive technology that something happened — which is what a live region is for. If you disable the submit button while the request runs, disable it *after* you have built the `FormData`, because a disabled named button would otherwise drop out of the entry list.
- Why prefer new FormData(form) over reading each input's value individually?Because the constructor applies the browser's own entry-list rules — named and enabled controls only, unchecked boxes skipped, controls attached by the `form` attribute included, repeated names preserved. Hand-rolled collection drifts the moment someone adds a field, disables one, or reuses a name, and it produces a payload that differs from what a native submission would have sent.
- Object.fromEntries(formData) looks tidy. When does it lose data?Whenever a name appears more than once — checkbox groups, multi-value pickers, any repeated field. An object holds one value per key, so earlier entries are overwritten and only the last survives. Use `formData.getAll(name)` for those fields, and build the object deliberately rather than flattening blindly if the endpoint expects JSON.
- What can a formdata listener do, and what can it not?It can read and mutate `event.formData` — append, set or delete entries — before they are serialized, which is how a custom widget contributes state that no control holds. It cannot cancel the submission: the event is not cancelable. Blocking a submit belongs in the `submit` handler, and field-level checks belong to the validation layer.
- After preventDefault, what does the page now owe the user that the browser used to handle?Everything around the request: a visible pending state, a guard against double submission, an error path when the network fails, and a success outcome the user can perceive. Since there is no navigation, a screen-reader user gets no automatic signal, so the result needs to be announced — typically by writing it into a live region — and focus needs to be moved somewhere sensible.
saying these in an interview costs you the question
- Sets Content-Type manually when posting a FormData body
- Builds FormData without passing the submitter, losing the button entry
- Flattens FormData with Object.fromEntries and drops repeated names
- Calls JSON.stringify on a FormData object and sends {}
- Thinks the formdata event can cancel a submission