Merged
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Form library with hooks!
Motivation
In the Elasticsearch UI team we build many forms. Many many forms! 😊 For each of them, we manually define the form state & update and validate its value. Basically re-inventing the wheel for each new form. It takes our precious dev time to re-think the approach each time, but even more problematic: it means that each of our form is built slightly differently. Maintaining those forms means that each time we need to remember how the state is updated and how validation works for a specific form. This is far from efficient...
We need a system in place that takes care of the repetitive task of managing a form state and validating its value, so we can dedicate more time doing what we love the most: build amazing UX for our community! 😊
Main building blocks
useForm()hook &<UseField />The main building blocks of the library are the
useForm()hook and the<UseField />component. The former is used to intantiate a form, you call it once and then forward the form object returned to the<UseField />component. As you probably have guessed,<UseField />is the Component you will use to declare a field on the form.Let's go through some example:
That is all we need to create a form with a "title" property and listen for the input changes.
By default,
<UseField />will render an<input type="text" />to hold the value. Of course, this is just to illustrate how the two building blocks work, in other examples we will see how to customize the UI.Design decisions
Raw form state
In order to allow the most flexibility and also improve performance, the form data is saved as a flat object. Only when submitting the form, the output object is built and returned to the consumer. This is the reason why a form field is given a
pathand not anameto identify it.For array values, we would probably have dynamic rows, but this was just to illustrate how paths work. As you can see, updating the output form data shape is straightforward. Also, say goodbye to serialize and unserialize payloads for Elasticsearch. 😊
Performance
A lot of effort has been put in optimizing for performance. In our current forms, we usually have a state object with the form values and errors. On each keystroke, we update the state and thus re-render the whole form. On small form this is OK, but on large forms with many inputs, this is not efficient.
This is the reason why each form Field is wrapped inside its own component, managing its internal state of value + errors. This also implies that you can't directly access the form data and react to changes on a field value. Of course, there is a solution, have a look at "Access the form data" below.
Customize the UI
There are two ways to provide a custom UI:
Approach 1: Render props pattern
As you can see, you have complete control on how a field is rendered in the DOM. The hook form library does not bring any UI with it, it just returns form and field objects to help building forms.
For our concrete Elasticsearch UI forms, this is a lot of boilerplate to simply render a text field with some error message underneath it. In a following PR I have prepared some goodies, let's call them "syntactic sugar Component" that will remove all the repetitive JSX so we can much more easily spot what is unique to a field (name, label, helpText, validation).
Approach 2: Passing a Component
Another way to customize the UI is to pass a Component as a prop.
This has the benefit of separation of concern. On one side we build the form, declaring its fields, on the other, we build the UI for each field.
Field configuration
<UseField />accepts an optionalconfigprop to provide field configuration. This is where we will define the default value, formatters, (de)Serializer, validation...Note: All the configuration settings are optional.
The first thing you see is that we have declared 2 default values. The prop
defaultValuetakes over the config one. This is because the config object might be declared statically in a separate file with a default value to be used in create mode. (for example for a toggle, if it'strueorfalseby default). Then, on the field, when you are in edit mode, you want to override any default value from the config and provide the value that comes from the API request.This is great, but as our form grows, declaring configuration object inline would quickly clutter our form component. A better way to declare the fields configuration is through a form schema.
Form Schema
A form schema is simply an object mapping to fields configuration. It means: cutting the inline configuration from the JSX above and putting it inside an object.
And we're back to a nice, easy to read,
<MyForm />component! As you might have guessed, the UseFieldpathmaps to the schema objectproperties.The schema does not need to be flatten though
Validation
We finally get to it! 😊 You validate a field by providing a set of
Validationobjects to thevalidationarray of the field configuration. TheseValidationobjects have one required property: avalidator()function. This validator function will receive thevaluebeing validated, along with theformDatain case we need to validate the value against other form fields.The
validator()functions can be synchronous or asynchronous (see below), and they will be executed sequentially until one of them fails. Failing means returning anErrorobject. If nothing is returned from the validator it means that it succeeded.As we can see, field validator function are pure functions (when doing synchronous validation) that receive a value and return an
Errorobject orvoid. As we know, a lot of the validation we do in our forms check for the same type of error (is the field empty?, does the string start with "x"?, is this a valid index pattern?, is this a valid time value?...). Those field validator functions are a good candidate for other pieces of reusable static code. In my separate "goodies" PR I will include some of them. The idea is that we grow our list of reusable field validators functions and only add business-specific validators inside the form schema whenever it does not make sense to make reusable.Asynchronous validation
In some cases, we need to make an HTTP Request to validate a field value (for example to validate an index name). The good news is... asynchronous validation works exactly the same way as synchronous validation! You can mix both, the only thing to bear in mind is if one of the validation is asynchronous, calling
field.validate()will need to be "awaited".Default value object
We have seen earlier how we can provide a default value on the
<UseField />component through props:If we only have a few fields, this is good enough. But if we have a complex form data object, this will quickly become tedious as we might have to drill down the
myFetchedResourceobject to child components of our<MyForm />main component.To solve this, when initiating the form we can provide an optional
defaultValueobject. When a field is added on the form, if there is a value on thisdefaultValueobject at the fieldpath, it will be used as the default value.Adding the "edit" capability to a form has never been so easy 😊
Dynamic form row with
<UseArray />In case your form requires dynamic fields, the
<UseArray />component will help you add and remove them.Access the form data with
<FormDataProvider />As mentioned above, for performance reason, you can't access the form data on the
formobject and get updates in your view when a field value change. That does not mean it is not possible to do. Whenever you need to react to a field value change, you can use the<FormDataProvider />component.The
pathsToWatchis optional. It can be a path or an Array of paths. It allows you to declare which form field(s) you are interested in. If you don't specify thepathsToWatch, it will update on any field value change. For performance, you should always provide a value for it.cc @cjcenizal