Why a form library?
MobX is powerful enough to manage form state on its own, and for many forms you probably don't need a form library.
But if you notice yourself repeatedly implementing dirty flags, validation, dynamic lists, and conversion to an API payload, mobx-form-lite provides a thin set of helpers for them.
Side by side comparison
Let's build a form for editing an article with the following requirements:
- Title is required
- Description must be at most 80 characters long
- There must be at least one tag, and every tag is required
- The Save button is disabled until a field changes
- Validation errors are hidden until the first submit
Naive Mobx implementation
import { makeAutoObservable } from 'mobx'
import { api } from './api'
function validateRequired(value: string) {
if (!value.trim()) return 'This field is required'
}
function validateDescription(value: string) {
if (value.length > 80) return 'Use 80 characters or fewer'
}
function atLeastOne(items: unknown[]) {
if (!items.length) return 'Add at least one tag'
}
export class ArticleStore {
form = {
title: {
value: 'Why a form library?',
isDirty: false,
isTouched: false,
error: undefined as string | undefined,
},
description: {
value: 'Small helpers for MobX forms',
isDirty: false,
isTouched: false,
error: undefined as string | undefined,
},
tags: {
values: [
{
value: 'mobx',
isDirty: false,
isTouched: false,
error: undefined as string | undefined,
},
{
value: 'forms',
isDirty: false,
isTouched: false,
error: undefined as string | undefined,
},
],
isDirty: false,
isTouched: false,
error: undefined as string | undefined,
},
}
constructor() {
makeAutoObservable(this, {}, { autoBind: true })
}
changeTitle(value: string) {
this.form.title.value = value
this.form.title.isDirty = true
this.form.title.error = validateRequired(value)
}
changeDescription(value: string) {
this.form.description.value = value
this.form.description.isDirty = true
this.form.description.error = validateDescription(value)
}
changeTag(index: number, value: string) {
const tag = this.form.tags.values[index]
tag.value = value
tag.isDirty = true
tag.error = validateRequired(value)
}
addTag() {
this.form.tags.values.push({
value: '',
isDirty: false,
isTouched: false,
error: 'This field is required',
})
this.form.tags.isDirty = true
this.form.tags.error = undefined
}
removeTag(index: number) {
this.form.tags.values.splice(index, 1)
this.form.tags.isDirty = true
this.form.tags.error = atLeastOne(this.form.tags.values)
}
get isDirty() {
return (
this.form.title.isDirty ||
this.form.description.isDirty ||
this.form.tags.isDirty ||
this.form.tags.values.some((tag) => tag.isDirty)
)
}
get isValid() {
return (
!this.form.title.error &&
!this.form.description.error &&
!this.form.tags.error &&
this.form.tags.values.every((tag) => !tag.error)
)
}
submit() {
// if form is invalid - show all the errors to user
if (!this.isValid) {
this.form.title.isTouched = true
this.form.description.isTouched = true
this.form.tags.isTouched = true
this.form.tags.values.forEach((tag) => {
tag.isTouched = true
})
return
}
// manually convert every field to plain data
return api.saveArticle({
title: this.form.title.value,
description: this.form.description.value,
tags: this.form.tags.values.map((tag) => tag.value),
})
}
}None of this code is particularly complicated but that's a lot of boilerplate.
Using mobx-form-lite
import { makeAutoObservable } from 'mobx'
import { formTouchAll, formToPlain, isFormDirty, isFormValid, ListField, TextField } from 'mobx-form-lite'
import { api } from './api'
function validateRequired(value: string) {
if (!value.trim()) return 'This field is required'
}
function validateDescription(value: string) {
if (value.length > 80) return 'Use 80 characters or fewer'
}
function atLeastOne(items: unknown[]) {
if (!items.length) return 'Add at least one tag'
}
function createTag(value = '') {
return new TextField(value, { validate: validateRequired })
}
export class ArticleStore {
form = {
title: new TextField('Why a form library?', { validate: validateRequired }),
description: new TextField('Small helpers for MobX forms', {
validate: validateDescription,
}),
tags: new ListField([createTag('mobx'), createTag('forms')], {
validate: atLeastOne,
}),
}
constructor() {
makeAutoObservable(this, {}, { autoBind: true })
}
addTag() {
this.form.tags.push(createTag())
}
removeTag(index: number) {
this.form.tags.removeByIndex(index)
}
get isDirty() {
return isFormDirty(this.form)
}
submit() {
// if form is invalid - show all the errors to user
if (!isFormValid(this.form)) {
formTouchAll(this.form)
return
}
// convert the entire form to plain data recursively
return api.saveArticle(formToPlain(this.form))
}
}TextField holds the value, dirty state, touched state, and validation error. ListField does the same for the dynamic list. isFormDirty, isFormValid, and formTouchAll work recursively, including every tag.
The 2 states have different purposes: isDirty disables Save until something changes, while isTouched keeps errors hidden until the first submit.
<form
onSubmit={(event) => {
event.preventDefault()
store.submit()
}}
>
<input
value={store.form.title.value}
onChange={(event) => store.form.title.onChange(event.target.value)}
/>
{/* Validation errors are hidden until the first submit */}
{store.form.title.isTouched && store.form.title.error ? (
<div>{store.form.title.error}</div>
) : null}
{/* other form fields */}
<button type='submit' disabled={!store.isDirty}>
Save
</button>
</form>As you see, mobx-form-lite provides those reusable pieces while leaving the form as regular MobX state.