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:
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.
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 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
Store
Let's start modeling the form. We'll create a ResumeStore
that will hold the form state:
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:
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";
}
};
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:
const createExperienceItem = () => ({
company: new TextField("", { validate: validateRequired }),
years: new TextField("", {
validate: validators.all(validateRequired, validateInt),
}),
});
How to use it in the store:
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
:
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
:
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>
);
});
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.
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:
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>
);
});
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:
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:
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: