Relatively recently, in TC39, a proposal for the implementation of decorators in ECMAScript reached the 3rd pre-final stage. A little later, MicroSoft released the 5th major version of TypeScript, where a new implementation of decorators started working out of the box without any experimental flags. Babel as well took the initiative, and in its documentation began to recommend using a new implementation of decorators. And all that means that decorators have finally begun to fully enter the lives of JavaScript developers.
And on this hype train, I decided to tell you how, using decorators, you can improve your Developer Experience when developing forms.
It is important to mention that in this article, I will write about an approach based on using the MobX library. So if you don't use it in your projects, the article may not be as useful as it could be. But you can consider reading it as a possible source of inspiration for how to develop web forms.
A little bit of background
In my previous project, I had to develop a lot of complex forms. Often, they consisted of several dozen fields.
Most of the fields had to be validated. Of course, simple validation rules, such as checking for the fullness of a field or checking for the validity of an email address, appear quite often. But sometimes these rules were incredibly complicated. For example, depending on what the user chooses in one field, the validation rules for another field could change. And in some cases, it was necessary to turn off the validation of one field with certain values of the other. And, of course, there could be several validation rules for each field, each of which had to issue its own error message.
But validation was only part of the necessary functionality. There must also be a form changes tracking. Relatively speaking, disable the submit button until the user makes some changes. But, again, there were dozens of fields on forms, so writing if's for each field was not the best solution.
The situation was aggravated by the fact that some fields could represent arrays and sets of data. And if the user deleted several values from such a field, and then entered the same values manually, the form should have understood that it had returned to its original state.
On top of this, the forms had to be able to reset the current state of the form to the original one. And of course, they had to be able to communicate with the server.
I considered using various libraries, such as React Hook Form or Formik, but these options did not suit me. At the scale of those requirements, the code, even with these libraries, turned out to be too cumbersome and difficult to maintain. So I started thinking about my own solution.
MobX Form Schema
The separation of representation and logic is where I started. It was necessary to think of a way to somehow describe the logic of the form in a separate function or object and somehow minimize the need to write repeatable code as much as possible.
In the end, I came to the conclusion that it is convenient to describe the logic of the form in a separate JavaScript class. Later in the text, I will call such a class a "form schema". Each property of such a class can represent a field in a form or perform some kind of utilitarian purpose. And with the help of decorators, you can assign the necessary logic separately for each property.
In its simplest representation, such an object is a regular MobX store. For example, the code snippet below shows the simplest example of a form schema consisting of two fields: "First Name" and "Last Name". So far, without any logic.
import { makeObservable, observable } from 'mobx';
export class BasicFormStore {
name = '';
surname = '';
constructor() {
makeObservable(this, {
name: observable,
surname: observable,
});
}
}
Form validation
What is field validation? This is one or more rules for checking the value of a field. In our case, the "field" is a property of the class. This means that several validation rules can be assigned to the corresponding property using the decorator: @validate
.
import { FormSchema, validate } from '@yoskutik/mobx-form-schema';
import { makeObservable, observable } from 'mobx';
import { email, required } from 'path/to/validators';
export class LoginSchema extends FormSchema {
@validate(required(), email())
email = '';
constructor() {
super();
makeObservable(this, {
email: observable,
});
}
}
const schema = LoginSchema.create();
console.log(schema.isValid, schema.errors);
// false, { email: 'The field is required' }
schema.email = 'invalid.email';
console.log(schema.isValid, schema.errors);
// false, { email: 'Invalid email format' }
schema.email = 'valid@email.com';
console.log(schema.isValid, schema.errors);
// true, {}
You simply pass several validator functions to the decorator, and the schema validates the field value itself. If there are several validators, the schema applies them sequentially. And only if all the rules are successfully passed, the schema tells that the field is valid.
And again, we see the separation of the code. The logic of the form is kept separate from the declaration of validators. Of course, this is done on purpose. Thus, an approach to writing atomic validation functions is formed. And thanks to this, the general rules can be easily reused.
What does a validator function look like?
A validator function for a schema is simply a function that returns either a string or a boolean value.
export const required = () => (value?: string) => {
if (value?.trim()) return false;
return 'This field is required';
};
export const email = () => (value: string) => {
if (/\S+@\S+\.\S+/.test(value)) return false;
return 'Invalid email format';
};
export const minLength = (min: number) => (value: string) => {
if (value.length >= min) return false;
return `Should be at least ${min} characters.`;
};
If the function returns false, validation is considered successful. And if a string or true - no. Moreover, the string passed in the validator becomes an error message for the field.
As the 1st input parameter, the function receives the current value of the property. And in the case of complex validation, each validator function takes an entire schema with all the properties as the 2nd parameter.
The “confirm password” field must have exactly the same value as the “password” field. The date input field "From" must contain the date that was before the date in the "To" field. These are basic examples of when we need to use an entire schema for validation.
The example below shows a sign-up form with an example of validating the password confirmation field.
import { FormSchema, validate } from '@yoskutik/mobx-form-schema';
import { makeObservable, observable } from 'mobx';
import { email, minLength, required } from 'path/to/validators';
const confirmPassword = () => (
// Let’s use the second argument in order to understand whether
// the “confirmPassword” is the same as “password”
(confirmPasswordValue: string, schema: SignUpSchema) => {
if (confirmPasswordValue === schema.password) return false;
return 'Passwords mismatched';
}
);
export class SignUpSchema extends FormSchema {
// Email address field
@validate(required(), email())
email = '';
// Password field
@validate(required(), minLength(8))
password = '';
// Password confirmation field
@validate(required(), confirmPassword())
confirmPassword = '';
constructor() {
super();
makeObservable(this, {
email: observable,
password: observable,
confirmPassword: observable,
});
}
}
Conditional validation
As I pointed out earlier, sometimes there may be situations when validation needs to be turned off. Fields may be optional or hidden for some reason, and in some cases, validation needs to be disabled depending on the values in other fields.
Since the @validate
decorator is already used for the declaration of casual validation, we cannot use it. But we can create its modifier: @validate.if
. Such a modifier will work almost the same as the original one, with the only exception that, in addition to the array of validators, a predicate function needs to be passed, which tells whether validation is needed at the moment. If the predicate says that validation is not needed, the property is recognized as valid.
The example below shows a scheme of three fields:
- Optional field for entering the email address.
- A checkbox in which the user says that he has a pet.
- And a field for entering the pet's name. If the checkbox is active, it is necessary to perform validation for the fullness of the field.
import { FormSchema, validate } from '@yoskutik/mobx-form-schema';
import { makeObservable, observable, runInAction } from 'mobx';
import { email, required } from 'path/to/validators';
const shouldValidatePetName = (_name: string, schema: ConditionalSchema) => (
schema.doesHavePet
);
export class ConditionalSchema extends FormSchema {
// or it can be @validate.if(email => !!email, [email()])
@validate.if(Boolean, [email()])
email = '';
doesHavePet = false;
@validate.if(shouldValidatePetName, [required()])
petName = '';
constructor() {
super();
makeObservable(this, {
email: observable,
doesHavePet: observable,
petName: observable,
});
}
}
const schema = ConditionalSchema.create();
console.log(schema.isValid, schema.errors); // true, {}
runInAction(() => schema.doesHavePet = true);
console.log(schema.isValid, schema.errors);
// false, { petName: 'The value is required.' }
runInAction(() => schema.email = 'invalid.email');
console.log(schema.isValid, schema.errors);
// false, {
// petName: 'The value is required.',
// email: 'Invalid email format.',
// }
This is a fairly simple validation example. You can take a look at an example of more complex validation, including conditional validation and rules that use an entire schema, on the documentation site.
When the validation happens?
By default, the schema calculates the validation in the autorun
function from the MobX library. Due to this, the validation of the property is recalculated automatically when it is changed. But also because of this, if other properties of the schema participated in the validation, when they are changed, the validation will be recalculated as well.
The same rule works for the validation condition function. If the desired property or a property that participates in the condition has been changed, the predicate function will be called again.
You should not worry about unnecessary recalculations. Thanks to MobX and MobX Form Schema optimizations, there are none of them. However, it is possible to disable automatic validation and start validating data manually. You can look at examples of manual validation at the link.
A brief summary of the validation
These advantages may seem subjective, but I really liked this approach in the design of the code. It is well-readable and well-maintainable. It is flexible enough, and even in complex cases, it does not force you to write sophisticated logic.
The downside may seem to be the need to write validators from scratch. Even the most basic ones. While other libraries supply them out of the box. But I have objections to this point:
- Even the basic rules may differ for different projects. For example, validation of a phone number or email address in different countries.
- The application can support multiple languages. And even within the same language, there may be situations when the same rule in different fields should give different error messages.
Both of these points lead to the need to provide functionality for overriding or configuring basic validators. But as you've seen for yourself, basic validators can consist of 3 lines of code. And for me, it's better to write 3 lines of code from scratch than to write them for the configuration of the out-of-the-box functionality.
What's also great is that MobX From Schema works with decorators of both new and old implementations. But the new one has good typing support. Therefore, I can't pass a validator for a number to a string property.
const rule = () => (value: number) => {
if (value > 0) return false;
return 'The value must be greater than 0';
};
export class SignUpSchema extends FormSchema {
// a typing error here, since `rule` must work with number properties
@validate(rule())
email = '';
}
The track of form changes
Now let's move from validation to tracking form changes. It is not difficult to understand that the form has been changed. First, you need to save the initial state of the form. Secondly, at the desired moment, it is enough to use such a piece of code:
const isChanged = currentValue1 !== initialValue1
|| currentValue2 !== initialValue2
|| ...;
This is an effective and simple way, but it is only suitable for simple forms. The more fields there are in the form, the longer this condition will be and the more difficult it will be to maintain such code.
But this is not the only problem. In addition to simple text fields, there may be more complex fields in the form. For example, on most career-based websites, there’s a field for specifying your skill set. And the value of such a field should, in fact, be either an array or a set. And a simple reference comparison will not help here to understand whether the state of the form has changed.
There is another approach: deep comparison.
import isEqual from 'lodash/isEqual';
const isChanged = isEqual(currentState, initialState);
This approach solves the problems described above. But then there are problems with unnecessary calculations. Ideally, the form must tell if it changed or not upon any user interaction. But for each change, calling for a deep comparison can be too heavy.
MobX allows you to work around both of these problems. In the form schema, when a certain field is changed, only the comparison that checks whether this particular field has been changed takes place.
And to activate the tracking of changes of a form, it is enough to use the decorator: @watch
.
export class UserSchema extends FormSchema {
@watch name = 'Initial name';
@watch surname = 'Initial surname';
}
const schema = UserSchema.create();
console.log(schema.isChanged) // false
schema.name = 'New Name';
console.log(
schema.isChanged, // true
schema.getInitial('name'), // 'Initial name'
);
schema.name = 'Initial name';
console.log(schema.isChanged) // false
In this form, the isChanged
flag will always be false
if the first name is equal to "Initial name"
and the last name is equal to "Initial surname"
. Even if the property changes its value to another one and then returns to its original state.
The @watch
decorator uses the reference comparison and tells the schema whether the value of the property has changed from its initial state.
You may have noticed that I didn't call the makeObservable
function in the example above. This is all because @watch
applies observable.ref
to properties by default. It is done for logical reasons - if you only need a reference comparison, you will hardly need deep observation through the observable
. However, you can add it or any other observable
modifications yourself without any problems.
More importantly, the schema itself does not remember its initial state. But if you apply the @watch
, the scheme will save the initial state only for the desired fields. Thus, there is no overhead of memory consumption.
Complex objects tracking
Due to reference comparison, @watch
is mostly needed to observe primitive values. It is a bit more complicated when it comes to observing objects. For example, when checking whether an array has been changed, we need to check the number of elements in the initial and current states and also check that the elements match at each of the positions.
But decorator modifiers come to our aid again. With their help, we can create such modifiers in which, instead of a reference comparison, there will be some other type of comparison. For example, if you want to use an array of values, use @watch.array
, and if there is a set, use @watch.set
. By default, these decorators will apply to shemas’s properties the observable.shallow
.
import { FormSchema, watch } from '@yoskutik/mobx-form-schema';
import { runInAction } from 'mobx';
export class ArraySchema extends FormSchema {
@watch.array skillsArray = ['HTML', 'CSS', 'JavaScript'];
@watch.set skillsSet = new Set(['HTML', 'CSS', 'JavaScript']);
}
const schema = ArraySchema.create();
runInAction(() => schema.skillsArray = ['HTML']);
console.log(schema.isChanged, schema.changedProperties);
// true, Set(['skillsArray'])
runInAction(() => schema.skillsArray.push('CSS', 'JavaScript'));
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
runInAction(() => schema.skillsSet.delete('CSS'));
console.log(schema.isChanged, schema.changedProperties);
// true, Set(['skillsSet'])
runInAction(() => schema.skillsSet.add('CSS'));
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
Once again, new decorators work better with typings. You cannot apply @watch.array
to not an array property nor @watch.set
to not a set property.
Nested schemas tracking
Schemas can be nested. The simplest reason for this is the logical division of data. For example, in the user information form, information about his contacts can be a separate schema nested in the main schema.
import { FormSchema, watch } from '@yoskutik/mobx-form-schema';
import { runInAction } from 'mobx';
export class ContactsSchema extends FormSchema {
@watch tel = 'default tel value';
@watch email = 'default email value';
}
export class InfoSchema extends FormSchema {
@watch name = '';
@watch surname = '';
@watch.schema contacts = ContactsSchema.create();
}
const schema = InfoSchema.create();
runInAction(() => schema.contacts.tel = 'new value');
console.log(schema.isChanged, schema.changedProperties);
// true, Set(['contacts'])
runInAction(() => schema.contacts.tel = 'default tel value');
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
runInAction(() => schema.contacts = ContactsSchema.create());
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
And, of course, such a nested separate schema can be used in the future without a parent scheme if such a situation is required.
In addition to this, you can use the @watch.schemasArray
modifier in case you need to use an array of nested schemas. Such an array, for example, can be an array of working experience information blocks in a CV form.
In case you have some unusual data structure that requires an unusual comparison function, you can create a modification of the @watch
decorator yourself. For this, you can use the watch.create
method. But in order not to inflate the article too much, I will just modestly leave a link to the documentation if you are interested in looking at examples of using @watch.schemasArray and watch.create.
Restoring form to its original state
In some cases, the form restoring function may be useful. Especially in editing forms with pre-filled data from the server. And since we already keep the original state of the form, it is not difficult at all for us to restore the form to its original state.
import { FormSchema, watch } from '@yoskutik/mobx-form-schema';
import { runInAction } from 'mobx';
export class BasicSchema extends FormSchema {
@watch name = 'Joe';
@watch surname = 'Dough';
}
const schema = BasicSchema.create();
runInAction(() => {
schema.name = 'new name';
schema.surname = 'new surname';
});
console.log(schema.name, schema.surname); // 'new name', 'new surname'
schema.reset();
console.log(schema.name, schema.surname); // 'Joe', 'Dough'
And, of course, arrays, sets, nested schemas, and even your custom entities (if you describe them correctly) — all of them will be correctly restored.
A brief summary of the changes tracking
I will briefly mention that, as with validation, changes are tracked automatically. But, of course, the possibility of manually checking changes is also available.
And so, with just a couple of decorators and methods, fully automated tracking of form changes is ready. No matter how complex the scheme is or how many nested schemes there are in it, you can always understand whether your form has changed. And you can always restore it to its original state.
You can even create an IDE settings form schema. Usually, in such forms, there are many tabs, inside which there are nested tabs. And you can easily track changes and reset the form entirely, only at a specific tab or only at a nested tab.
At the same time, these observations are quite cheap. When a field is changed, only this field will be checked.
And, of course, the fact that @watch
and its modifications are able to apply observable modifications from MobX on properties by themselves allows you to reduce your code even more.
Form communication with a server
Sometimes the data received from the server requires some kind of preprocessing before being used. For example, the server cannot send a Set or Date entity, but it may be more convenient for you to use the data in this format.
There may be a reverse situation, the server may require data in a different format from the one in which it is stored in a schema.
And usually, developers who experience such a need modify the data after receiving it, before using it, or before sending it. But with a form schema, such modifications can be described directly in the diagram.
Initialization
In the previous examples, you saw that to create a schema, you need to call the create
static method. This method can accept an object as an argument, on the basis of which the schema can be filled with data.
import { FormSchema } from '@yoskutik/mobx-form-schema';
export class BasicSchema extends FormSchema {
name = '';
surname = '';
}
const schema1 = BasicSchema.create();
console.log(schema1.name, schema1.surname); // '', ''
const schema2 = BasicSchema.create({
name: 'Joe',
surname: 'Dough',
});
console.log(schema2.name, schema2.surname); // 'Joe', 'Dough'
And you can also describe how the data obtained in this method should be preprocessed before the scheme starts using it. To do this, you can use the @factory
decorator.
import { factory, FormSchema } from '@yoskutik/mobx-form-schema';
const createDate = (data: string) => new Date(data);
export class BasicSchema extends FormSchema {
@factory.set
set = new Set<number>();
@factory(createDate)
date = new Date();
}
const schema = BasicSchema.create({
set: [0, 1, 2],
date: '2023-01-01T00:00:00.000Z',
});
console.log(schema.set instanceof Set, schema.date instanceof Date);
// true, true
Presentation
Since each schema contains utilitarian data and methods, it may be useful for you to get an object that contains exclusively useful data from a schema. To do this, you can use the presentation getter of the schema, which by default creates a copy of the schema without utilitarian methods and properties.
import { FormSchema } from '@yoskutik/mobx-form-schema';
export class BasicSchema extends FormSchema {
name = 'Joe';
surname = 'Dough';
}
const schema = BasicSchema.create();
console.log(schema.presentation);
// {
// name: 'Joe',
// surname: 'Dough',
// }
You can also use the @present
decorator to change the contents of the presentation object. And you can even cut off some of its properties. For example, you hardly want to send the value of the password confirmation field to the server. To do this, you can use the @present.hidden
modifier.
import { FormSchema, present } from '@yoskutik/mobx-form-schema';
const presentUsername = (username: string) => `@${username}`;
export class BasicSchema extends FormSchema {
@present(presentUsername)
username = 'joe-man';
name = 'Joe';
@present.hidden
someUtilityProperty = 'utility data';
}
const schema = BasicSchema.create();
console.log(schema.presentation);
// {
// name: 'Joe',
// username: '@joe-man',
// }
And later, you can use this very presentation object when sending data to the server.
But how to use it in React?
I created MobX Form Schema as a package with a minimal set of dependencies. So it is not necessary to use React. The only thing needed is MobX - this one has to be in the project.
But nevertheless, I understand that in most cases, MobX is used with React, so I have prepared an example of using my library in a React application. But in order not to inflate the article, I'll just attach links to them: the web documentation, CodeSandbox.io, StackBlitz.com.
And just to keep you interested, I'll briefly show you how a component for displaying a form with the pet's name from the "Conditional Validation" section might look like.
export const ConditionalExample = observer(() => {
const schema = useMemo(() => ConditionalSchema.create(), []);
return (
<form>
{/* Since error output is standardized, TextField is able to display them itself */}
<TextField schema={schema} field="email" type="email" label="E-mail" />
<CheckboxField schema={schema} field="doesHavePet" label="I have a pet" />
{schema.doesHavePet && (
<TextField schema={schema} label="Pet's name" field="petName" required />
)}
<button type="submit">Submit</button>
</form>
);
});
So, does it mean that decorators are the silver bullet?
Am I trying to assert in my article that this approach to form development is the only correct one? No, of course, there are no silver bullets in development. But it really seemed to me that this approach simplifies the development process. And, importantly, it has almost no effect on the size of your bundle - apart from MobX itself, all the functionality that I described is stored in a package of less than 4 KB. And considering that you will have to write less code, you can only win in terms of the size of the bundle.
Moreover, this approach works well both on small-sized and on large-scale forms.
However, yes, you need MobX. At least in my implementation. If someone does something similar for other state managers, it will be interesting for me to see it.
The end
In the article, I showed most but not all the functionality of the form schema. I have not shown how validation and changes tracking work in manual mode; I have not shown all modifiers of the decorators. And in general, if you are interested in this approach to form development, I recommend that you visit the documentation site. There are a lot of useful things there, including useful scenarios for using the form schema.
I am waiting for your feedback in the comments. How do you like this approach in general?
A link to the npm package.
Bye.
Top comments (0)