DRY form handling with Lit
Tags: lit, form, webcomponents
Published: 2024-02-20 02:58:04

## The problem

One of the main compelling reasons to use web components is the possibility to use the web platform with as little abstraction as possible. It is a path that brings many benefits, but not without its problems. One of them is the handling of forms. Not that is hard, is simply tedious and counter productive.

In [Lit](https://lit.dev) world, there's not a standard way of handling forms. A long time [discussion](https://github.com/lit/lit/discussions/2489) could not agree in a good / generic enough solution.

Here, i present my take, that tries to meet the following criteria: 

 - the component state (property) is the source of truth of the form, i.e., use controlled inputs
 - use native HTML form markup as much as possible
 - leverage the DOM events to communication
 - DRY

## Imperative approach

This uses native `FormData` to retrieve the form data on submit. Works for small form without initial values (based on component state) and do not need to interact with the data dynamically.

```js
class ContactForm extends LitElement {
  formSubmit(e) {
    // prevents sending the form
    e.preventDefault()
    const form = e.target

    const formData = new FormData(form)
    const formValues = Object.fromEntries(formData.entries())

    console.log(formValues)
    // returns something like
    // {
    //   "name": "João Silva",
    //   "birthDate": "1978-06-21",
    //   "subject": "Reclamação",
    //   "acceptRules": "on"
    // }

    const name = formValues.name
    // converts to Date
    const birthDate = parseISO(formValues.birthDate)
    const subject = formValues.subject
    // converts to boolean
    const acceptRules = Boolean(formValues.acceptRules)

    // we have the form data
    const finalData = { name, birthDate, subject, acceptRules }
  }

  render() {
    return html`
      <form @submit=${this.formSubmit}>
        <label for="name">Nome:</label>
        <input type="text" id="name" name="name" required />

        <label for="birthDate">Data de nascimento:</label>
        <input type="date" id="birthDate" name="birthDate" required />

        <label for="subject">Assunto:</label>
        <select id="subject" name="subject" required>
          <option value="">-- Select One --</option>
          <option value="Reclamação">Reclamação</option>
          <option value="Elogio">Elogio</option>
        </select>

        <label for="acceptRules">Aceito as condições:</label>
        <input type="checkbox" id="acceptRules" name="acceptRules" />

        <input type="submit" value="Submit" />
      </form>
    `
  }
}
```

## Hardcoded event approach

This version goes some steps further, making the component state (property) the form source of truth at the same time is being updated dynamically.

Basically it does:
 - Set the form input value declaratively to the corresponding property using lit template syntax
 - Attach a generic event listener to respective event ("change", "input") of each input
 - In the event listener: 
   - Format the value according to field type
   - uses the input name attribute to update the property

```js
class ContactForm extends LitElement {
  @property()
  contactData = {
    acceptRules: true,
  }

  inputHandler(e) {
    e.preventDefault()
    e.stopPropagation()
    const input = e.target

    let value

    switch (input.type) {
      case 'checkbox':
        // get boolean value
        value = input.checked
        break
      case 'date':
        // get Date value
        value = parseISO(input.value)
        break

      default:
        value = input.value
        break
    }

    // update property using name attribute
    const property = input.getAttribute('name')
    this.contactData = { ...this.contactData, [property]: value }
  }

  formSubmit(e) {
    e.preventDefault()
    console.log(this.contactData)
  }

  render() {
    return html`
      <form @submit=${this.formSubmit}>
        <label for="name">Nome:</label>
        <input
          type="text"
          id="name"
          name="name"
          required
          .value=${this.contactData.name || null}
          @input=${this.inputHandler}
        />

        <label for="birthDate">Data de nascimento:</label>
        <input
          type="date"
          id="birthDate"
          name="birthDate"
          required
          .valueAsDate=${this.contactData.birthDate || null}
          @input=${this.inputHandler}
        />

        <label for="subject">Assunto:</label>
        <select id="subject" name="subject" required @change=${this.inputHandler}>
          <option value="">-- Select One --</option>
          <option value="Reclamação" ?selected=${this.contactData.subject === 'Reclamação'}>
            Reclamação
          </option>
          <option value="Elogio" ?selected=${this.contactData.subject === 'Elogio'}>Elogio</option>
        </select>

        <label for="acceptRules">Aceito as condições:</label>
        <input
          type="checkbox"
          id="acceptRules"
          name="acceptRules"
          .checked=${this.contactData.acceptRules}
          @change=${this.inputHandler}
        />

        <input type="submit" value="Submit" />
      </form>
    `
  }
}
```

### Reusable event approach

The third interaction is basically the previous one refactored to be used by any component / property.

It leverages the fact that lit template assigns the component to `this` in [event handler](https://lit.dev/docs/components/events/#understanding-this-in-event-listeners)



```js
function createFormInputHandler(property) {
  return inputHandler(e) {
    e.preventDefault()
    e.stopPropagation()
    const input = e.target

    let value

    switch (input.type) {
      case 'checkbox':
        // get boolean value
        value = input.checked
        break
      case 'date':
        // get Date value
        value = parseISO(input.value)
        break

      default:
        value = input.value
        break
    }

    // update property using name attribute
    // could use lodash set to handle nested path
    const subProperty = input.getAttribute('name')
    this[property] = { ...(this[property] || {}), [subProperty]: value }
  }
}

const inputHandler = createFormInputHandler('contactData')

class ContactForm extends LitElement {
  @property()
  contactData = {
    acceptRules: true,
  }  

  formSubmit(e) {
    e.preventDefault()
    console.log(this.contactData)
  }

  render() {
    return html`
      <form @submit=${this.formSubmit}>
        <label for="name">Nome:</label>
        <input
          type="text"
          id="name"
          name="name"
          required
          .value=${this.contactData.name || null}
          @input=${inputHandler}
        />

        <label for="birthDate">Data de nascimento:</label>
        <input
          type="date"
          id="birthDate"
          name="birthDate"
          required
          .valueAsDate=${this.contactData.birthDate || null}
          @input=${inputHandler}
        />

        <label for="subject">Assunto:</label>
        <select id="subject" name="subject" required @change=${inputHandler}>
          <option value="">-- Select One --</option>
          <option value="Reclamação" ?selected=${this.contactData.subject === 'Reclamação'}>
            Reclamação
          </option>
          <option value="Elogio" ?selected=${this.contactData.subject === 'Elogio'}>Elogio</option>
        </select>

        <label for="acceptRules">Aceito as condições:</label>
        <input
          type="checkbox"
          id="acceptRules"
          name="acceptRules"
          .checked=${this.contactData.acceptRules}
          @change=${inputHandler}
        />

        <input type="submit" value="Submit" />
      </form>
    `
  }
}
```

### The final solution?

The last approach meets the criteria, with a markup close to the metal but still with some room for improvement like removing the need to set the `inputHandler` for each field.

In fact, i already use such solution: [FormState](https://github.com/blikblum/nextbone/blob/master/form.js#L208), a Reactive Controller that listen to form inputs 'change' and 'input' events, store touched and validation state. Unfortunately is tied to [nextbone](https://github.com/blikblum/nextbone) Model class. Hopefully i can make it generic someday...