MobX-state-tree

MobX-state-tree

  • Documentation
  • TypeDocs
  • Sponsor
  • GitHub

›Recipes

Introduction

  • Welcome to MobX-State-Tree!
  • Installation
  • Getting Started Tutorial
  • Examples
  • Overview & Philosophy

Basic Concepts

  • Types, models, trees & state
  • Actions
  • Derived values
  • React and MST
  • Snapshots
  • Identifiers and references
  • Asynchronous actions

Advanced Concepts

  • Patches
  • Listening to changes
  • Dependency Injection
  • Middleware
  • Reconciliation
  • Volatile state

API Overview

  • Types overview
  • API overview
  • Lifecycle hooks overview

Tips

  • Talks & Blogs
  • Frequently Asked Questions
  • TypeScript and MST
  • Circular dependencies
  • Simulating inheritance
  • Using snapshots as values
  • Miscellaneous Tips

Compare

  • React Context vs. MobX-State-Tree

Recipes

  • Auto-Generated Property Setter Actions
  • Pre-built Form Types with MST Form Type
  • Manage Asynchronous Data with mst-query
Edit

Pre-built Form Types with MST Form Type

Find the mst-form-type library on npm: https://www.npmjs.com/package/mst-form-type.

Introduction

Libraries like Ant Design have a built-in Form component that holds field status and validation rules. Integrating a Form component with MobX State Tree models can pose significant challenges as business logic become more complex.

That's where a solution like mst-form-type library comes into play. It models the Ant design field management like a conventional MobX-State-Tree type definition. Users can still use the UI of a component library and keep logic inside MobX-State-Tree, instead of syncing status changes between Form component and model.

Please note: The mst-form-type library primarily provides model types for the form structure. It does not encompass business logic related to field interactions.

Setup

Setting up and utilizing the mst-form-type library is straightforward:

  1. Install the mst-form-type library via npm:
npm install mst-form-type
  1. Ensure you have mobx-state-tree ^5.0.0 installed:
npm install mobx-state-tree@^5.0.0

Now that the general installation is complete, let's explore how to use it through a simple example. Although mobx-state-tree is designed to handle complex web application states, you might find the example a bit over-designed. However, the aim is to illustrate the idea of why using mst-form-type is helpful, so I've kept the logic as simple as possible.

Example

To demonstrate the difference between mst-form-type and the Form component, I've created a comparative example. The form is straightforward, featuring two static fields and a dynamic field group with two fields inside. Certain fields have validation rules to illustrate how validation functions. Additionally, the value of a static field changes when the number of dynamic fields reaches a certain threshold.

Ant Design Form component

I've utilized the Ant Design Form component for the example, although other libraries should have very similar implementations. Let's take a look at the Ant Design version first.

import React, { useState, useEffect } from 'react'
import { Form, Input, Select, Button, InputNumber } from 'antd'

const { Option } = Select

const MyForm = () => {
  const [form] = Form.useForm()
  // extra state to handle field interaction logic
  const [members, setMembers] = useState([{ name: '', age: '' }])
  const [planValue, setPlanValue] = useState('')

  useEffect(() => {
    // handle field interaction in effect
    if (members.length > 1 && planValue === 'A') {
      form.setFieldsValue({ plan: 'B' })
    }

    if (members.length > 3 && planValue !== 'C') {
      form.setFieldsValue({ plan: 'C' })
    }
  }, [members, planValue, form])

  const onFinish = values => {
    console.log('Received values:', values)
  }

  // handle field interaction by another set of onChange events
  const handleAddMember = cb => {
    if (members.length <= 5) {
      setMembers([...members, { name: '', age: '' }])
      cb()
    }
  }

  const handleRemoveMember = (index, cb) => {
    const updatedMembers = [...members]
    updatedMembers.splice(index, 1)
    setMembers(updatedMembers)
    cb(index)
  }

  const handlePlanChange = value => {
    setPlanValue(value)
  }

  return (
    {/* useForm hook provides a form instance to hold field status */}
    <Form form={form} onFinish={onFinish}>
      {/* field need name props for form instance */}
      <Form.Item
        name="name"
        label="Name"
        rules={[{ required: true, message: 'Please input your name!' }]}
      >
        <Input />
      </Form.Item>
      <Form.Item
        name="plan"
        label="Plan"
        rules={[{ required: true, message: 'Please select a plan!' }]}
      >
        {/* extra event handler for field logic */}
        <Select onChange={handlePlanChange}>
          <Option value="A" disabled={members.length > 1}>
            A
          </Option>
          <Option value="B" disabled={members.length >= 3}>
            B
          </Option>
          <Option value="C">C</Option>
        </Select>
      </Form.Item>
      <Form.List name="members">
        {(fields, { add, remove }) => (
          <>
            {fields.map(({ key, name, ...restField }, index) => (
              <div key={key}>
                <Form.Item
                  {...restField}
                  name={[name, 'name']}
                  label="Member Name"
                  rules={[{ required: true, message: 'Please input member name!' }]}
                >
                  <Input />
                </Form.Item>
                <Form.Item {...restField} name={[name, 'age']} label="Member Age">
                  <InputNumber />
                </Form.Item>
                {/* extra event handler for field logic */}
                <Button type="link" onClick={() => handleRemoveMember(index, remove)}>
                  Remove
                </Button>
              </div>
            ))}
            <Form.Item>
              <Button
                type="dashed"
                onClick={() => {
                  // pass form instance method as callback
                  handleAddMember(add)
                }}
                style={{ width: '100%' }}
              >
                Add Member
              </Button>
            </Form.Item>
          </>
        )}
      </Form.List>
      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>
  )
}

export default MyForm

The code reveals that to manage field logic, we need to maintain a second copy of the field value in React state and attach additional event handlers to Input or other components. Subsequently, we handle the logic within useEffect hooks using form instance APIs.

However, this approach can quickly become unmaintainable as the logic complexity increases. Furthermore, it is challenging to integrate with other application states in MST. This is because MST does not inherently manage field statuses; instead, the form instance does so.

mst-form-type

Now, let's examine how the same example looks when using mst-form-type. The code can be split into two files: one for the UI and the other for the model. Let's start by reviewing the model file.

// model.ts
import { types } from "mobx-state-tree"
import createForm from "mst-form-type"

// define form in schema
export const FormSchema = {
  static: [
    {
      id: "name",
      default: "",
      validator: "required"
    },
    {
      id: "plan",
      default: "A"
    }
  ],
  dynamic: [
    {
      id: "member",
      limit: 5,
      schema: [
        {
          id: "name",
          default: "",
          validator: "required"
        },
        {
          id: "age",
          default: "",
          validator: "required"
        }
      ],
      default: [{ name: "John", age: 20 }],
      onAdd: (i) => {
        // hooks run when add dynamic fields
        console.log("add", i)
      },
      onRemove: (i) => {
        // hooks run when remove dynamic fields
        console.log("remove", i)
      },
      onEdit: (i) => {
        // hooks run when edit dynamic fields, only be called when edit field by form action
        console.log("edit", i)
      }
    }
  ]
}

// App model
export const Example = types
  .model("FormExample")
  .props({
    form: createForm(FormSchema) // form as a model type
  })
  .views((self) => ({
    get disableA() {
      return self.form.member.size > 1
    },
    get disableB() {
      return self.form.member.size > 3
    }
  }))
  .actions((self) => ({
    onAddFields() {
      // field logic
      if (self.form.member.size > 1 && self.form.plan.value === "A") {
        self.form.plan.setValue("B")
      } else if (self.form.member.size > 3 && self.form.plan.value !== "C") {
        self.form.plan.setValue("C")
      }
    }
  }))

In the model file, we declare a form schema and create the form model. Subsequently, all relevant field models are created within the form model. An action is added to the model to handle field logic, illustrating that mst-form-type doesn't manage business logic but solely holds form and field statuses.

Now, let's examine how the model is utilized by the UI, which is still built using Ant Design.

// model.ts
import React, { useContext } from 'react'
import { Form, Input, Select, Button, InputNumber } from 'antd'
import { model, ModelContext } from '~/models'
import { observer } from 'mobx-react-lite'

const { Option } = Select

const MyForm = () => {
  // get model instance via context
  const model = useContext(ModelContext)

  const onFinish = values => {
    // 在这里计算价格
    console.log('Received values:', model.form.submit())
  }

  return (
    {/* no form instance need */}
    <Form onFinish={onFinish}>
      {/* get field status from MST */}
      <Form.Item
        label="Name"
        validateStatus={model.form.name.invalid && 'error'}
        help={model.form.name.invalid && model.form.name.msg}
      >
        {/* controlled component using MST props and action */}
        <Input
          value={model.form.name.value}
          onChange={e => model.form.name.setValue(e.target.value)}
        />
      </Form.Item>
      <Form.Item label="Plan">
        <Select value={model.form.plan.value} onChange={value => model.form.plan.setValue(value)}>
        {/* computed value */}
          <Option value="A" disabled={model.disableA}>
            A
          </Option>
          <Option value="B" disabled={model.disableB}>
            B
          </Option>
          <Option value="C">C</Option>
        </Select>
      </Form.Item>
      <>
        {/* render dynamic fields */}
        {model.form.member.fields.map(({ id, ...field }) => {
          return (
            <div key={id}>
              {/* access individual dynamic fields */}
              <Form.Item label="Member Name">
                <Input
                  value={field.name.value}
                  onChange={e => {
                    field.name.setValue(e.target.value)
                  }}
                />
              </Form.Item>
              <Form.Item label="Member Age">
                <InputNumber
                  value={field.age.value}
                  onChange={e => field.age.setValue(e.target.value)}
                />
              </Form.Item>
              <Button
                type="link"
                onClick={() => {
                  // dynamic fields built-in action
                  model.form.member.removeFields(id)
                }}
              >
                Remove
              </Button>
            </div>
          )
        })}
        <Form.Item>
          <Button
            type="dashed"
            onClick={() => {
              model.form.member.addFields({ name: '', age: 20 })
              // handle business logic
              model.onAddFields()
            }}
          >
            Add Member
          </Button>
        </Form.Item>
      </>
      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>
  )
}
// wrap the component by mobx observer
export default observer(MyForm)

Essentially, mst-form-type eliminates the need for a form instance, allowing us to have only one copy of field status, which resides within the MST. Components no longer need to manage form-related states; instead, they simply read state from the MST and render accordingly.

There are two methods to retrieve or set field values. One involves using actions on each field instance, as demonstrated in the example code. The other method utilizes actions on the form type instance, specifying the field id defined in the form schema. Refer to the "APIs" section below for more details.

How It Works

The mst-form-type library exports a function which creates a form model (based on the base form model under the hood) using the provided schema and an optional name. Each form type model create its own type, and then instance by MST, which makes every attribute and method independent. So that you can have multiple form type props in a single MST model. User can conveniently interact with the form field via form props, or apply the form model actions to directly get or set field values. Let's see the architecture of mst-form-type first, and then we will go through most of the useful APIs

Architecture

The library defines three model types under the hood:

  • Field Model: Contains props and actions of a form field, such as value, default, id, valid(), etc.

  • Group Model: Built for handling a collection of field models as dynamic fields. Each dynamic field group should maintain uniformity in structure. The model also provides actions as life cycle hooks for adding, editing, and removing dynamic fields.

  • Base Form Model: Encapsulates all field and group models and the associated form methods. The exported function will build a form model with fields defined in schema on top of the base form model.

APIs

default export

The default exported method will generate a new custom types.model with all the fields in the schema as props, based on a base model type. The newly created form type will automatically initialize with the schema upon creation. Optionally, a name can be passed for tracking purposes; otherwise, it will default to the base model name.

type TValidator = "required" | ((...args: any[]) => boolean) | RegExp | undefined | null

type TValue = string | boolean | number | Record<string, string> | Array<any>

interface FieldSchema {
  id: string
  type?: "string" | "number" | "boolean" | "object" | "array"
  default: TValue
  validator?: TValidator
  msg?: string
}

Field

schema
type TValidator = "required" | ((...args: any[]) => boolean) | RegExp | undefined | null

type TValue = string | boolean | number | Record<string, string> | Array<any>

interface FieldSchema {
  id: string
  type?: "string" | "number" | "boolean" | "object" | "array"
  default: TValue
  validator?: TValidator
  msg?: string
}
props

id

types.identifier. The key of each field in a form, and will become the form type props key. It should be unique and will be used to access field value and in setValue() form action.

value

The props hold the field value. The value type can be string, boolean, number, object, array. object and array will be tranform to types.frozen as a MST leaf.

default

The default value of a field. The Mobx State Tree will decide prop type based on the type of this value.

validator

Optional & Private. All validators will be called in valid() before submit().

'required' means this field cannot be falsy values, like 0, '', or undefined.

((...args: any[]) => boolean) means a function return a boolean value. If returned true, the validation will be treated passed.

RegExp means the value will be used in RegExp.test(). If returned true, the validation will be treated passed.

undefined | null will not be processed.

msg

Optional. Message shows when field is invalid. The default message is 'The input is invalid'

invalid

Compute value. Return revert value of invalid() result

actions

setValue(value)

Update field value.

valid()

Run field validator if it has.

reset()

Reset field value to default value.

setValidator(rawValidator: TValidator)

Change field validator after initialization

init(field: IField)

Rerun field initialization

setErrorMsg(msg: string)

Change invalid message

clear()

Set field value to null

code example
form[id].value
form[id].invalid
form[id].setValue("new-value")
form[id].reset()
form[id].valid()

Dynamic Field Group

schema
interface DynamicFields {
  id: string
  limit: number
  schema: FieldSchema | FieldSchema[]
  default?: Array<Record<string, TValue>>
  onAdd?: (field) => any
  onRemove?: (field) => any
  onEdit?: (field) => void
}
props

id

types.identifier. The key of each dynamic field group in a form, and will become the form type props key. It should be unique and will be used to access field value and in setDynamicValue() form action.

fields

An array holds all dynamic field models. schema in the interface is to define field schema here. Object in default array will be used to create dynamic fields when form initializing.

limit

Optional. Maxium dynamic field allowed. default is -1, means unlimited.

actions

addFields(i, isInit = false)

Add new dynamic field i. You don't need to pass isInit flag when calling the action. It is used for not calling onAdd hooks in schema when initialization.

removeFields(id: string)

Remove the field with specific id. This action will call the onRemove hook if passed.

editField(id: string, fieldKey: string, value)

Edit fieldKey field with id to value. This action will call the onEdit hook if passed.

getValues()

Get all dynamic field values/

valid()

Valid all dynamic field.

reset()

Reset all dynamic fields.

code example
form[id].fields.map(field => { ... })
form[id].addFields(field)
form[id].removeFields('id')
form[id].editField('id', 'key', 'value')
form[id].getValues() // get all dynamic field values, rarely used
form[id].valid() // valid all dynamic field, rarely used
form[id].reset() // reset all dynamic field, rarely used

Form

schema
interface FormSchema {
  static: FieldSchema[]
  dynamic?: DynamicFields[]
}
props

fields

Every field in schema will become a field prop of form type. The type of each field will be based on default value.

submission

A snapshot of last success submitted form values, in Key-Value object format.

error

An object in Key-Value format contains validation error of each field. This will be cleared on every valid() call.

_internalStatus

Indicate the form status, has 4 values: 'init', 'pending', 'success', 'error'. Usually you don't need it, it will change according to form status.

loading

Compute value. Return true when form status is 'pending'. Designed for avoiding duplicated submission.

actions

setValue({ key, value })

Set static field value in a form type. _internalStatus is reserved.

setDynamicValue({ groupId, id, key, value })

Set dynamic field value in a form type. Each dynamic field has a field group id and a field id. Or you can use the setValue action on instance to do the same job.

submit()

It will return all field values if passed validation in Key-Value format object. The last valid submission will be hold in submission props.

The method will not submit the form in any form of action. It only output the form current values. You need to handle to real submission action yourself.

init()

It will be called after new custom type created with schema. It will process the schema to get default values and validators.

valid()

It will run all validators in schema with current field values. It will be called in submit(), and produce error if any error happens.

reset()

It will set the form type to init status, clearing submissions and errors. All fields will be set to default values passed by schema.

code example
form.setValue({ key, value })
form.setDynamicValue({ groupId, id, key, value })
form.submit()
form.rest()
form.onAdd(id, field)
form.onRemove(groupId, fieldId)
form.onEdit(groupId, fieldId, key, value)
form.clear(groupId)
← Auto-Generated Property Setter ActionsManage Asynchronous Data with mst-query →
  • Introduction
  • Setup
  • Example
    • Ant Design Form component
    • mst-form-type
  • How It Works
    • Architecture
    • APIs
MobX-state-tree
Docs
Getting StartedBasic conceptsAdvanced conceptsAPI Reference
Community
GitHub DiscussionsStack Overflow
More
BlogGitHubStar
Facebook Open Source
Copyright © 2025 Michel Weststrate