Rambler Group experience: how we started to fully control the formation and behavior of front-end React components

There are many ways to create a modern web application, but each team inevitably faces approximately the same set of questions: how to distribute front and back duties, how to minimize the appearance of duplicate logic - for example, when validating data, which libraries to use, how to ensure reliable and transparent transport between front and back and document the code.
In our opinion, we were able to implement a good example of a solution balanced in complexity and profit, which we successfully use in production based on Symfony and React.
What kind of data exchange format can we choose when planning the development of a backend API in an actively developing web product that contains dynamic forms with related fields and complex business logic?
- SWAGGER is a good option, there are documentation and convenient tools for debugging. Especially for Symfony, there are libraries that allow you to automate the process, but unfortunately JSON Schema turned out to be preferable;
- JSON Schema - this option was offered by frontend developers. They already had libraries that allow them to display forms on its basis. This determined our choice. The format allows you to describe primitive checks that can be done in the browser. There is also documentation that describes all possible schema options;
- GraphQL is pretty young. Not such a large number of server side and frontend libraries. At the time of the creation of the system was not considered, in the future - the best way to create API, this will be a separate article;
- SOAP - has a strong data typing, the ability to build documentation, but it is not so easy to make friends with the React front. Also, SOAP has a greater overhead by the same usable amount of transmitted data;
All these formats did not completely cover our needs, so I had to write my combine harvester. Such an approach can provide high-performance solutions for any particular application, but it carries risks:
- high probability of bugs;
- often not 100% documentation and test coverage;
- low “modularity” due to the closeness of the software API. Typically, such solutions are written as a monolith and do not imply a sharing between projects as components, as this requires a special architectural construction (read the cost of development);
- high level of entry of new developers. It can take a lot of time to understand all the coolness of a bike;
Therefore, it is good practice to use common and stable libraries (like a npm left-pad) according to the rule - the best code is the one that you never wrote, and you solved the business problem. Backend web solutions development in Rambler Group advertising technologies is conducted on Symfony. We will not dwell on all the components of the framework used, below we will talk about the main part, on the basis of which the work is implemented - the Symfony form . React and the corresponding library extending JSON Schema for WEB specificity - React JSON Schema Form is used on the frontend .
The general scheme of work:

This approach gives many advantages:
- the documentation is generated out of the box, as is the ability to build automatic tests - again according to the scheme;
- all transmitted data is typed;
- it is possible to transfer information about basic validation rules;
Fast integration of the transport layer into React - due to the Mozilla React JSON Schema library; - the ability to generate web components on the frontend out of the box by integrating bootstrap;
- logical grouping, a set of validations and possible values of HTML elements, as well as all business logic is controlled at a single point - on the backend, there is no duplication of code;
- porting the application to other platforms as easily as possible - the view part is separated from the manager (see the previous paragraph); instead of React and the browser, Android or iOS applications can be used to render and process user requests;
Let's look at the components and their interaction scheme in more detail.
Initially, JSON Schema allows you to describe primitive checks that can be done on the client, like binding or typing different parts of the scheme:
const schema = {
"title": "A registration form",
"description": "A simple form example.",
"type": "object",
"required": [
"firstName",
"lastName"
],
"properties": {
"firstName": {
"type": "string",
"title": "First name"
},
"lastName": {
"type": "string",
"title": "Last name"
},
"password": {
"type": "string",
"title": "Password",
"minLength": 3
},
"telephone": {
"type": "string",
"title": "Telephone",
"minLength": 10
}
}
}
There is a popular React JSON Schema Form library for working with the front-end schema , which gives you the necessary add-ons for web development on JSON Schema:
uiSchema - JSON Schema itself determines the type of parameters passed, but this is not enough to build a web application. For example, a field of type String can be represented as <input ... /> or as <textarea ... />, these are important nuances with regard to which you need to correctly draw the scheme for the client. To transfer these nuances, uiSchema also serves, for example, for the above JSON Schema, you can specify the visual web component of the following uiSchema:
const uiSchema = {
"firstName": {
"ui:autofocus": true,
"ui:emptyValue": ""
},
"age": {
"ui:widget": "updown",
"ui:title": "Age of person",
"ui:description": "(earthian year)"
},
"bio": {
"ui:widget": "textarea"
},
"password": {
"ui:widget": "password",
"ui:help": "Hint: Make it strong!"
},
"date": {
"ui:widget": "alt-datetime"
},
"telephone": {
"ui:options": {
"inputType": "tel"
}
}
}
Live Playground example can be viewed here .
With this use of the scheme, rendering on the frontend will be implemented by standard components of bootstrap in several lines:
render((
<Formschema={schema}uiSchema={uiSchema} />
), document.getElementById("app"));
If the standard widgets supplied with bootstrap do not suit you and you need customization - for some data types you can specify your templates in uiSchema, at the time of this writing, string , number , integer , boolean are supported .
FormData - contains form data, for example:
{
"firstName": "Chuck",
"lastName": "Norris",
"age": 78,
"bio": "Roundhouse kicking asses since 1940",
"password": "noneed"
}
After rendering, the widgets will be filled with this data - useful for editing forms, as well as for some of the custom mechanisms we added for related fields and complex forms, see below.
More information about all the nuances of setting up and using the sections described above can be found on the page of the plugin .
Out of the box, the library allows you to work only with these three sections, but for a full-fledged web application you need to add some more features:
Errors- it is also necessary to be able to pass errors of various backend checks for drawing to the user, and errors can be either simple validation errors - for example, for the uniqueness of the login when registering a user, or more complex ones based on business logic - i.e. we should be able to customize their (errors) number and text of displayed notifications. For this, the Errors section was added to the transmitted data set, besides the ones described above. Here, for each field, a list of errors for drawing the
Action and Method are defined. Two attributes were added to the backend for the user-prepared data, containing the URL of the processing controller and the HTTP method Delivery
As a result, for communication between the front and the back, json has turned out with the following sections:
{
"action": "https://...",
"method": "POST",
"errors":{},
"schema":{},
"formData":{},
"uiSchema":{}
}
But how to generate this data on the back end? At the time of the creation of the system there were no ready-made libraries to convert the Symfony Form into JSON Schema. Now they have already appeared, but have their drawbacks - for example, LiformBundle treats JSON Schema fairly freely and changes the standard at its own discretion, therefore, unfortunately, I had to write my own implementation.
Standard symfony forms are used as the basis for generation . It is enough to use the builder and add the required fields:
Sample form
$builder
->add('title', TextType::class, [
'label' => 'label.title',
'attr' => [
'title' => 'title.title',
],
])
->add('description', TextareaType::class, [
'label' => 'label.description',
'attr' => [
'title' => 'title.description',
],
])
->add('year', ChoiceType::class, [
'choices' => range(1981, 1990),
'choice_label' => function($val){
return $val;
},
'label' => 'label.year',
'attr' => [
'title' => 'title.year',
],
])
->add('genre', ChoiceType::class, [
'choices' => [
'fantasy',
'thriller',
'comedy',
],
'choice_label' => function($val){
return'genre.choice.'.$val;
},
'label' => 'label.genre',
'attr' => [
'title' => 'title.genre',
],
])
->add('available', CheckboxType::class, [
'label' => 'label.available',
'attr' => [
'title' => 'title.available',
],
]);
At the output, this form is converted into a type scheme:
JsonSchema example
{
"action": "//localhost/create.json",
"method": "POST",
"schema": {
"properties": {
"title": {
"maxLength": 255,
"minLength": 1,
"type": "string",
"title": "label.title"
},
"description": {
"type": "string",
"title": "label.description"
},
"year": {
"enum": [
"1981",
"1982",
"1983",
"1984",
"1985",
"1986",
"1987",
"1988",
"1989",
"1990"
],
"enumNames": [
"1981",
"1982",
"1983",
"1984",
"1985",
"1986",
"1987",
"1988",
"1989",
"1990"
],
"type": "string",
"title": "label.year"
},
"genre": {
"enum": [
"fantasy",
"thriller",
"comedy"
],
"enumNames": [
"genre.choice.fantasy",
"genre.choice.thriller",
"genre.choice.comedy"
],
"type": "string",
"title": "label.genre"
},
"available": {
"type": "object",
"title": "label.available"
}
},
"required": [
"title",
"description",
"year",
"genre",
"available"
],
"type": "object"
},
"formData": {
"title": "",
"description": "",
"year": "",
"genre": ""
},
"uiSchema": {
"title": {
"ui:help": "title.title",
"ui:widget": "text"
},
"description": {
"ui:help": "title.description",
"ui:widget": "textarea"
},
"year": {
"ui:widget": "select",
"ui:help": "title.year"
},
"genre": {
"ui:widget": "select",
"ui:help": "title.genre"
},
"available": {
"ui:help": "title.available",
"ui:widget": "checkbox"
},
"ui:widget": "mainForm"
}
}
All code that converts forms to JSON is closed and is used only in the Rambler Group, if the community has an interest in this topic - we will
Let's look at a few more aspects without the implementation of which it is difficult to build a modern web application:
Validation of fields
It is defined using the symfony validator , which describes the rules for object validation, an example of a validator:
<propertyname="title"><constraintname="Length"><optionname="min">1</option><optionname="max">255</option><optionname="minMessage">title.min</option><optionname="maxMessage">title.max</option></constraint><constraintname="NotBlank"><optionname="message">title.not_blank</option></constraint></property>
In this example, constrain of type NotBlank modifies the schema by adding a field to the array of the required schema fields, and constrain of type Length adds the attributes schema-> properties-> title-> maxLength and schema-> properties-> title-> minLength, which validation should already take into account on the front end.
Item grouping
In real life, simple forms are the exception to the rule. For example, a project may have a form with a large number of fields and giving everything in a solid list is not the best option - we must take care of the users of our application:

The obvious solution is to divide the form into logical groups of control elements so that the user can more easily navigate and make fewer mistakes:

How You know, the capabilities of the Symfony Form out of the box are quite large - for example, forms can be inherited from other forms, this is convenient, but in our case there are disadvantages. In the current implementation, the order in JSON Schema determines the order of rendering of the form element in the browser, inheritance can violate this order. One option was to group items, for example:
Sample Nested Form
$info = $builder
->create('info',FormType::class,['inherit_data'=>true])
->add('title', TextType::class, [
'label' => 'label.title',
'attr' => [
'title' => 'title.title',
],
])
->add('description', TextareaType::class, [
'label' => 'label.description',
'attr' => [
'title' => 'title.description',
],
]);
$builder
->add($info)
->add('year', ChoiceType::class, [
'choices' => range(1981, 1990),
'choice_label' => function($val){
return $val;
},
'label' => 'label.year',
'attr' => [
'title' => 'title.year',
],
])
->add('genre', ChoiceType::class, [
'choices' => [
'fantasy',
'thriller',
'comedy',
],
'choice_label' => function($val){
return'genre.choice.'.$val;
},
'label' => 'label.genre',
'attr' => [
'title' => 'title.genre',
],
])
->add('available', CheckboxType::class, [
'label' => 'label.available',
'attr' => [
'title' => 'title.available',
],
]);
This form will be converted to the following form:
JsonSchema Nested Example
"schema": {
"properties": {
"info": {
"properties": {
"title": {
"type": "string",
"title": "label.title"
},
"description": {
"type": "string",
"title": "label.description"
}
},
"required": [
"title",
"description"
],
"type": "object"
},
"year": {
"enum": [
"1981",
"1982",
"1983",
"1984",
"1985",
"1986",
"1987",
"1988",
"1989",
"1990"
],
"enumNames": [
"1981",
"1982",
"1983",
"1984",
"1985",
"1986",
"1987",
"1988",
"1989",
"1990"
],
"type": "string",
"title": "label.year"
},
"genre": {
"enum": [
"fantasy",
"thriller",
"comedy"
],
"enumNames": [
"genre.choice.fantasy",
"genre.choice.thriller",
"genre.choice.comedy"
],
"type": "string",
"title": "label.genre"
},
"available": {
"type": "object",
"title": "label.available"
}
},
"required": [
"info",
"year",
"genre",
"available"
],
"type": "object"
}
and corresponding uiSchema
"uiSchema": {
"info": {
"title": {
"ui:help": "title.title",
"ui:widget": "text"
},
"description": {
"ui:help": "title.description",
"ui:widget": "textarea"
},
"ui:widget": "form"
},
"year": {
"ui:widget": "select",
"ui:help": "title.year"
},
"genre": {
"ui:widget": "select",
"ui:help": "title.genre"
},
"available": {
"ui:help": "title.available",
"ui:widget": "checkbox"
},
"ui:widget": "group"
}
This method of grouping did not suit us as the form for data begins to depend on the representation and cannot be used, for example, in API or other forms. It was decided to use additional parameters in uiSchema without breaking the current JSON Schema standard. As a result, additional options of the following type were added to the symphonic form:
'fieldset' => [
'groups' => [
[
'type' => 'base',
'name' => 'info',
'fields' => ['title', 'description'],
'order' => ['title', 'description']
]
],
'type' => 'base'
]
This will be converted to the following scheme:
"ui:group": {
"type": "base",
"groups": [
{
"type": "group",
"name": "info",
"title": "legend.info",
"fields": [
"title",
"description"
],
"order": [
"title",
"description"
]
}
],
"order": [
"info"
]
},
Full version of schema and uiSchema
"schema": {
"properties": {
"title": {
"maxLength": 255,
"minLength": 1,
"type": "string",
"title": "label.title"
},
"description": {
"type": "string",
"title": "label.description"
},
"year": {
"enum": [
"1989",
"1990"
],
"enumNames": [
"1989",
"1990"
],
"type": "string",
"title": "label.year"
},
"genre": {
"enum": [
"fantasy",
"thriller",
"comedy"
],
"enumNames": [
"genre.choice.fantasy",
"genre.choice.thriller",
"genre.choice.comedy"
],
"type": "string",
"title": "label.genre"
},
"available": {
"type": "boolean",
"title": "label.available"
}
},
"required": [
"title",
"description",
"year",
"genre",
"available"
],
"type": "object"
}
"uiSchema": {
"title": {
"ui:help": "title.title",
"ui:widget": "text"
},
"description": {
"ui:help": "title.description",
"ui:widget": "textarea"
},
"year": {
"ui:widget": "select",
"ui:help": "title.year"
},
"genre": {
"ui:widget": "select",
"ui:help": "title.genre"
},
"available": {
"ui:help": "title.available",
"ui:widget": "checkbox"
},
"ui:group": {
"type": "base",
"groups": [
{
"type": "group",
"name": "info",
"title": "legend.info",
"fields": [
"title",
"description"
],
"order": [
"title",
"description"
]
}
],
"order": [
"info"
]
},
"ui:widget": "fieldset"
}
Since on the frontend side , the React library we use does not support this out of the box, we had to add this functionality ourselves. With the addition of the “ui: group” element, we are able to fully control the process of grouping elements and forms using the current API.
Dynamic forms
What if one field depends on another, for example, a drop-down list of subcategories depends on the category selected?

Symfony FORM gives us the ability to make dynamic forms using Event's , but, unfortunately, at the time of implementation, this feature was not supported by JSON Schema, although in recent versions this feature has appeared . Initially, the idea was to give the entire list to the Enum and EnumNames object, based on which to filter the values:
{
"properties": {
"genre": {
"enum": [
"fantasy",
"thriller",
"comedy"
],
"enumNames": [
"genre.choice.fantasy",
"genre.choice.thriller",
"genre.choice.comedy"
],
"type": "string",
"title": "label.genre"
},
"sgenre": {
"enum": [
"eccentric",
"romantic",
"grotesque"
],
"enumNames": [
{
"title": "sgenre.choice.eccentric",
"genre": "comedy"
},
{
"title": "sgenre.choice.romantic",
"genre": "comedy"
},
{
"title": "sgenre.choice.grotesque",
"genre": "comedy"
}
],
"type": "string",
"title": "label.genre"
}
},
"type": "object"
}
But with this approach, for each such element, you will have to write your processing on the frontend, not to mention the fact that everything becomes very complicated when these objects become several or one element depends on several lists. In addition, the amount of data sent to the frontend for correct processing and rendering of all dependencies grows strongly. For example, imagine drawing a form consisting of three fields interconnected - countries, cities, streets. The amount of initial data that needs to be sent to the frontend backend can upset thin clients, and, as you remember, we need to take care of our users. Therefore, it was decided to implement the dynamics by adding custom attributes:
- SchemaID is a schema attribute that contains the address of the controller for processing the current entered FormData and updating the schema of the current form, if business logic requires it;
- Reload - an attribute telling the frontend that changing this field initiates a schema update by sending form data to the backend;
The presence of SchemaID may seem like duplication - there is an action attribute , but here we are talking about sharing responsibility - the SchemaID controller is responsible for the intermediate update of the schema and UISchema , and the action controller performs the necessary business action - for example, it creates or updates an object and does not allow sending part of the form performs validation checks. With these additions, the scheme begins to look like this:
{
"schemaId": "//localhost/schema.json",
"properties": {
"genre": {
"enum": [
"fantasy",
"thriller",
"comedy"
],
"enumNames": [
"genre.choice.fantasy",
"genre.choice.thriller",
"genre.choice.comedy"
],
"type": "string",
"title": "label.genre"
},
"sgenre": {
"enum": [],
"enumNames": [],
"type": "string",
"title": "label.sgenre"
}
},
"uiSchema": {
"genre": {
"ui:options": {
"reload": true
},
"ui:widget": "select",
"ui:help": "title.genre"
},
"sgenre": {
"ui:widget": "select",
"ui:help": "title.sgenre"
},
"ui:widget": "mainForm"
},
"type": "object"
}
In case of changing the “genre” field, the front-end sends the entire form with the current data entered to the backend, receives in response a set of sections necessary for drawing the form:
{
action: “https://...”,
method: "POST",
schema:{}
formData:{}
uiSchema:{}
}
and renders instead of the current form. What exactly changes after sending is determined by the back-up, the composition or the number of fields can change, etc. - any change required by the business logic of the application.
Conclusion
Due to a small expansion of the standard approach, we received a number of additional features that allow us to fully control the formation and behavior of front-end React components, build dynamic schemes based on business logic, have a single point of validation rules and the ability to quickly and flexibly create new VIEW parts - for example, mobile or desktop applications. Going into such bold experiments, you need to remember about the standard on the basis of which you work and to maintain backward compatibility with it. Instead of React on the frontend, any other library can be used, the main thing is to write the transport adapter to JSON Schema and connect any form rendering library. Bootstrap worked well for us with React since it had experience with this technology stack, but the approach about which we told you in no way limits your choice of technology. In place of Symfony, there could also be any other framework that allows you to convert forms into JSON Schema format.
Upd: you can see our report on Symfony Moscow Meetup # 14 about it from 1:15:00.