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>