Using web components you can create an easily reusable form component that handles this nicely.
function urlencodeFormData(fd: FormData) { let s = ''; function encode(s: string) { return encodeURIComponent(s).replace(/%20/g, '+'); } const formData: [string, string][] = []; fd.forEach((value, key) => { if (value instanceof File) { formData.push([key, value.name]); } else { formData.push([key, value]); } }); for (const [key, value] of formData) { s += (s ? '&' : '') + encode(key) + '=' + encode(value); } return s; } const xhrOnSubmit = (event: SubmitEvent) => { console.log('Form submitted'); const form: HTMLFormElement | null = event.target instanceof HTMLFormElement ? event.target : null; if (form == null) { console.error('Event target of form listener is not a form!'); return; } let baseUrl = form.action; if (baseUrl == null || baseUrl === '') { baseUrl = window.location.href; } const requestUrl = new URL(baseUrl, window.location.href); const shouldClear = form.getAttribute('data-clear-form') === 'true'; // Decide on encoding const formenctype = event.submitter?.getAttribute('formenctype') ?? event.submitter?.getAttribute('formencoding'); const enctype = formenctype ?? form.getAttribute('enctype') ?? form.getAttribute('encoding') ?? 'application/x-www-form-urlencoded'; // Decide on method let formMethod = event.submitter?.getAttribute('formmethod') ?? form.getAttribute('method')?.toLowerCase() ?? 'get'; const formData = new FormData(form); // Encode body let body: BodyInit | null = null; if (formMethod === 'get') { requestUrl.search = new URLSearchParams( urlencodeFormData(formData) ).toString(); } else if (formMethod === 'post') { if (enctype === 'application/x-www-form-urlencoded') { body = urlencodeFormData(formData); } else if (enctype === 'multipart/form-data') { body = formData; } else if (enctype === 'text/plain') { let text = ''; // @ts-ignore - FormData.entries() is not in the TS definition for (const element of formData.keys()) { text += `${element}=${JSON.stringify(formData.get(element))}\n`; } } else { throw new Error(`Illegal enctype: ${enctype}`); } } else if (formMethod === 'dialog') { // Allow default behavior return; } else { throw new Error(`Illegal form method: ${formMethod}`); } // Send request const requestOptions: RequestInit = { method: formMethod, headers: { 'Content-Type': enctype, }, }; if (body != null && formMethod === 'post') { requestOptions.body = body; } const response = fetch(baseUrl, requestOptions).then((response) => { if (shouldClear) { form.reset(); } if (response.ok) { form.dispatchEvent( new CustomEvent('xhr-form-success', { detail: response, }) ); } else { form.dispatchEvent( new CustomEvent('xhr-form-failure', { detail: response, }) ); } return response; }); event.preventDefault(); }; customElements.define( 'xhr-form', class extends HTMLFormElement { constructor() { console.log('Form constructed'); super(); } connectedCallback() { this.addEventListener('submit', xhrOnSubmit); } disconnectedCallback() { this.removeEventListener('submit', xhrOnSubmit); } }, { extends: 'form' } );
An example of use (everything to do with the events is optional):
<form action="/printer" method="post" id="xhr-form" is="xhr-form"> <h2>XHR POST Test</h2> <input type="text" name="name" placeholder="Name"> <input type="number" name="age" placeholder="Age"> <input type="submit" value="Submit"> </form> <script> const xhrForm = document.getElementById('xhr-form'); xhrForm.addEventListener('xhr-form-success', (event) => { console.log('XHR Form Success', event.detail); }); xhrForm.addEventListener('xhr-form-failure', (event) => { console.log('XHR Form Failure', event.detail); }); </script>