DEV Community

Luiz Américo
Luiz Américo

Posted on

DRY form handling with Lit

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 world, there's not a standard way of handling forms. A long time discussion 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.

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>
    `
  }
}
Enter fullscreen mode Exit fullscreen mode

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
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>
    `
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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>
    `
  }
}
Enter fullscreen mode Exit fullscreen mode

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, a Reactive Controller that listen to form inputs 'change' and 'input' events, store touched and validation state. Unfortunately is tied to nextbone Model class. Hopefully i can make it generic someday...

Top comments (0)