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>
`
}
}
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>
`
}
}
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>
`
}
}
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)