Skip to content

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

ts
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

ts
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.

tsx
<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.