Skip to content

Nested dynamic form

In this example we'll learn how to create a nested dynamic form with Mobx using mobx-form-lite. There is clickable demo in the end of the article. Make sure you have installed all the needed libraries to start:

bash
npm install mobx-form-lite antd

Task

We'll create a form that allows adding and removing fields dynamically. Fully runnable demo is available at the end of the article.

Nested dynamic form example

As well as providing the following rules:

  • Name, last name, and age are required
  • Age must be a number
  • At least one work experience must be provided
  • Total experience should be calculated automatically

Validation errors

  • Validation should support nested and dynamic fields
  • A work experience must have title and years as a number
  • Validation should be hidden until the first submit

Nested validation errors

Store

Let's start modeling the form. We'll create a ResumeStore that will hold the form state:

typescript
import { makeAutoObservable } from "mobx";
import { ListField, TextField } from "mobx-form-lite";

export type ExperienceItemType = {
  company: TextField<string>;
  years: TextField<string>;
};

export class ResumeStore {
  form = {
    name: new TextField(""),
    lastname: new TextField(""),
    fatherName: new TextField(""),
    age: new TextField(""),
    jobTitle: new TextField(""),
    experience: new ListField<ExperienceItemType>([]),
  };

  constructor() {
    makeAutoObservable(this, {}, { autoBind: true });
  }
}

Almost everywhere we've used TextField, but for the experience field, we've used ListField. ListField is a field that holds a list of fields. In our case, it holds a list of ExperienceItemType. The type is arbitrary and can be anything you want.

Validation rules

For the demo we'll write simple validation rules, but you can use any validation library you like:

ts
const validateRequired = (value: unknown) => {
  if (!value) {
    return "This field is required";
  }
};

const validateInt = (value: unknown) => {
  if (!Number.isInteger(Number(value))) {
    return "Please enter a valid number";
  }
};

const validateExperience = (items: ExperienceItemType[]) => {
  if (items.length === 0) {
    return "Please add at least one experience";
  }
};
ts
export class ResumeStore {
  form = {
    name: new TextField("", { validate: validateRequired }),
    lastname: new TextField("", { validate: validateRequired }),
    fatherName: new TextField(""),
    age: new TextField("", {
      validate: validators.all(validateRequired, validateInt),
    }),
    jobTitle: new TextField("", { validate: validateRequired }),
    experience: new ListField<ExperienceItemType>([], {
      validate: validateExperience,
    }),
  };

Note that validators is imported from mobx-form-lite. Feel free to write your own functions for validator composition.

Managing experience list

Let's add a function that adds an experience and attaches validation rules:

ts
const createExperienceItem = () => ({
  company: new TextField("", { validate: validateRequired }),
  years: new TextField("", {
    validate: validators.all(validateRequired, validateInt),
  }),
});

How to use it in the store:

ts
constructor() {
  makeAutoObservable(this, {}, { autoBind: true });
}

addExperience() {
  this.form.experience.push(createExperienceItem());
}

removeExperience(index: number) {
  this.form.experience.removeByIndex(index);
}

As you can see, the ListStore already provides some helper methods to manage an array. How to add experience calculation based on these fields? Since we're using MobX, we can easily calculate the total experience using computed:

ts
get sumYearExperience() {
  if (this.form.experience.value.some((item) => item.years.error)) {
    return 0;
  }

  return this.form.experience.value.reduce(
    (acc, item) => acc + Number(item.years.value),
    0,
  );
}

We only calculate the total experience if all the experience items are valid (field years have an integer value). The only thing left is to map the form state to React components.

Antd components

Let's create InputField and SelectField components that connect Antd with mobx-form-lite:

tsx
import { observer } from "mobx-react-lite";
import { TextField } from "mobx-form-lite";
import { Form, Input, Space, Typography } from "antd";

type Props = { field: TextField<string>; label: string };

export const InputField = observer((props: Props) => {
  const { field, label } = props;
  return (
    <Space direction={"vertical"} size={4}>
      <Typography.Text strong>{label}</Typography.Text>
      <Form.Item
        validateStatus={field.isTouched && field.error ? "error" : ""}
        help={field.isTouched && field.error ? field.error : ""}
      >
        <Input
          value={field.value}
          onChange={(e) => field.onChange(e.currentTarget.value)}
          onBlur={field.onBlur}
        />
      </Form.Item>
    </Space>
  );
});
tsx
import { observer } from "mobx-react-lite";
import { Select, Space, Typography } from "antd";
import { TextField } from "mobx-form-lite";

type Props = {
  id?: string;
  label?: string;
  field: TextField<string>;
  options: {
    label: string;
    value: string;
  }[];
};

export const SelectField = observer((props: Props) => {
  const { id, label, field, options } = props;
  return (
    <Space style={{ width: "100%" }} direction="vertical" size={4}>
      <Typography.Text strong>{label}</Typography.Text>
      <Select
        id={id}
        style={{ width: "100%" }}
        value={field.value}
        options={options}
        onChange={field.onChange}
      />
      {field.isTouched && field.error && (
        <Typography.Text type={"danger"}>{field.error}</Typography.Text>
      )}
    </Space>
  );
});

INFO

You write a field component once to adapt it to your UI kit and then reuse it anywhere in the project. The mobx-form-lite will release ready-to-use fields for the popular UI kits in the future.

Store context

To make the store available in the React components at any level of nesting, we’ll create a separate React context for this purpose. Since forms typically do not need to be global and exist only within a specific page, there is no need to use the singleton pattern.

tsx
import { createContext, ReactNode, useContext } from "react";
import { ResumeStore } from "./resume-store.ts";

const Context = createContext<ResumeStore | null>(null);

export const ResumeStoreProvider = (props: { children: ReactNode }) => {
  return (
    <Context.Provider value={new ResumeStore()}>
      {props.children}
    </Context.Provider>
  );
};

export const useResumeStore = () => {
  const store = useContext(Context);
  if (!store) {
    throw new Error("useResumeStore must be used within a ResumeStoreProvider");
  }
  return store;
};

Experience list

Experience list is an array of fields. We can render it like this:

tsx
import { observer } from "mobx-react-lite";
import { useResumeStore } from "./resume-store-context.tsx";
import { List, Typography } from "antd";
import { ExperienceItem } from "./experience-item.tsx";

export const ExperienceList = observer(() => {
  const resumeStore = useResumeStore();
  const { form } = resumeStore;

  return (
    <List>
      {form.experience.value.map((experience, i) => {
        return (
          <ExperienceItem
            key={i}
            item={experience}
            onRemove={() => {
              resumeStore.removeExperience(i);
            }}
          />
        );
      })}
      {form.experience.isTouched && form.experience.error ? (
        <Typography.Text type={"danger"}>
          {form.experience.error}
        </Typography.Text>
      ) : null}
    </List>
  );
});
tsx
import { observer } from "mobx-react-lite";
import { ExperienceItemType } from "./resume-store.ts";
import { Button, Flex, List } from "antd";
import { InputField } from "./input-field.tsx";

type Props = { item: ExperienceItemType; onRemove: () => void };

export const ExperienceItem = observer((props: Props) => {
  const { item, onRemove } = props;
  return (
    <List.Item>
      <Flex align="center" gap={8}>
        <InputField field={item.company} label={"Company"} />
        <InputField field={item.years} label={"Years"} />
        <Button onClick={onRemove}>Remove</Button>
      </Flex>
    </List.Item>
  );
});

Total experience

The total experience is a computed value. It's a separate component to reduce the render count of the main form:

tsx
import { observer } from "mobx-react-lite";
import { useResumeStore } from "./resume-store-context.tsx";

export const TotalExperience = observer(() => {
  const resumeStore = useResumeStore();
  return <div>Total experience (years): {resumeStore.sumYearExperience}</div>;
});

Runnable demo

This is how the root form component looks:

tsx
import { observer } from "mobx-react-lite";
import { Button, Card, Flex, Form, Space } from "antd";
import { InputField } from "./input-field.tsx";
import { SelectField } from "./select-field.tsx";
import { useResumeStore } from "./resume-store-context.tsx";
import { ExperienceList } from "./experience-list.tsx";
import { TotalExperience } from "./total-experience.tsx";

export const ResumeForm = observer(() => {
  const resumeStore = useResumeStore();
  const { form } = resumeStore;

  return (
    <Form
      onSubmitCapture={(e) => {
        e.preventDefault();
        resumeStore.submit();
      }}
    >
      <Card style={{ maxWidth: 600 }}>
        <Space direction="vertical" size={16}>
          <Flex gap={8}>
            <InputField field={form.name} label="First name" />
            <InputField field={form.lastname} label="Last name" />
            <InputField field={form.fatherName} label="Father name" />
          </Flex>
          <Flex gap={8}>
            <InputField field={form.age} label="Age" />
            <SelectField
              label="Occupation"
              field={form.jobTitle}
              options={[
                { label: "Con man", value: "conman" },
                { label: "Fortune teller", value: "fortune_teller" },
              ]}
            />
          </Flex>
          <Card
            actions={[
              <Button onClick={resumeStore.addExperience}>
                Add experience
              </Button>,
            ]}
          >
            <ExperienceList />
          </Card>
          <Flex justify="space-between">
            <TotalExperience />
            <Button htmlType={"submit"}>Submit</Button>
          </Flex>
        </Space>
      </Card>
    </Form>
  );
});

We have built a form using Mobx and mobx-form-lite that supports nested and dynamic fields. The form is type-safe and can be covered with unit tests without needing to set up a browser environment. We can leverage Mobx’s power and use computed on the form state to calculate derived values, as the form state is just an observable.

Enjoy the clickable demo and play with the code below: