-
Notifications
You must be signed in to change notification settings - Fork 0
Create Basic Form
On this page
- Creating a form
- Define your form fields
- Define your form and create its state
- Get a form manager
- Show form fields
- Validation
- Handling submit
Creating a form involves these steps:
- Defining a type for your form fields.
- Define the form and create its state.
- Optional define validation rules for the form fields.
- Get a form manager.
- Render form controls passing down the form manager as a prop.
- Handling submit.
You must create a type for the fields for your form.
type FormFields = {
name: string;
age: number;
};
If you already have a type from your backend, you can use it as is or
Pick
the relevant fields. You must ensure that the props on the type doesn't allownull
orundefined
. You can useRemoveOptional
from@ilbrando/utils
to ensure this.
const fd = useFormDefinition<FormFields>({
fields: {
name: {},
age: {}
}
});
This is the minimum requirements for useFormDefinition
. The type system requires you
to specify the fields
prop and a field definition for each of the props in FormFields
.
const fm = getFormManager(fd, false);
The form manager is the object you use to get and set values, change validation rules etc. It takes the
return value from useFormDefinition
and a boolean isSubmitting
(more on the latter later).
The example below uses the form controls from @ilbrando/simple-form-joy
.
<>
<FormTextField formManager={fm} fieldName="name" label="Name" />
<FormNumberField formManager={fm} fieldName="age" label="Age" />
</>
A FormTextField
is a component combining Joy FormControl
, FormLabel
and Input
and takes two extra props:
-
formManager
- the form manager you just created. -
fieldName
- the name from yourFormFields
this component handles.
The
fieldName
prop is typesafe so you can only choose props fromFormFields
and only props of a type the form control handles. This means when usingFormNumberField
you can only choose props of typenumber
(age
in the example).
The label
prop is a prop of Joy FormLabel
and is passed down to this component.
With the above you have two form fields which accepts input and updates the form state.
If you want to get the values, you can use fm.values.age
. This will return the value of the age field or null
if the field value is null
(the text field is empty).
Lets add some simple validation rules. Validation rules are functions that returns either
undefined
if there is no error or a string
if there is an error. The string
returned is also the
error message. simple-form
comes with some common validation rules, but you can easily create your own.
// Get some validation rules from simple-form.
const { required, maxLength, min } = useValidationRules();
const fd = useFormDefinition<FormFields>({
fields: {
name: {
validators: [required(), maxLength(20)]
},
age: {
validators: [required(), min(3)]
}
}
});
The above means that name
is required and can at most have a length of 20 characters and age
is also required
and must be greater than or equal to 3.
Which changes are needed for the FormTextField
and FormNumberField
?
NONE.
With the above changes the components show a *
next to the label (because the fields are required) and enforces the
validation rules.
The validation rules included in
simple-form
are functions which returns a validator (also a function). This is because they all take an optional custom error message as a parameter. You could userequired("Some error message")
instead ofrequired()
.
A field has an array of validators. They are executed in the order they appear in the array and the validation for a field is terminated when a validator returns an error.
Validation errors for all fields aren't shown right away because this would mean a user would be presented with a lot of errors before beginning filling out the form. Instead validation errors are first shown when a field is touched (it's value has been changed).
When a form is submitted the first thing to do is to validate all fields. This is done with the form manager fm.validateForm()
which besides validating all fields also marks them as touched (isTouched
is set to true
).
You have full access to the form state and can opt out of this as you like. This is really implemented in the form components and you can also implement them differently should you wish.
It's up to you what should trigger a submit and actually simple-form
doesn't deal with submit per se.
Typically you have a button and set the click handler to a function that performs the submit. This function should first
calls fm.validateForm()
and if this returns true
(meaning all validation rules are satisfied) it can proceed with submitting
the data to the backend (or where ever).
const handleSubmit = async () => {
if (fm.validateForm()) {
setIsSubmitting(true);
await backend.saveData(...)
setIsSubmitting(false);
}
};
You must handle the state isSubmitting
- as you may recall, you passed this information to getFormManager
as the second
parameter. You can hold it in React state or some other state system. After fm.validateForm()
has returned true
you
should set your isSubmitting
state to true
and perform the backend call. When this finishes you should set your
isSubmitting
state to false
(and handle any errors from your backend). The value you pass to getFormManager
is used
by the form controls (they are disabled when true
). It is a good idea to also disable the submit button when isSubmitting
is true
, so the user can't click it while data is being submitted.
It's completely up to you how you want to handle the
isSubmitting
state. If submit is just updating some local in memory data, it is probably so fast you can just passfalse
tofm.getFormManager(fd, false)
and not deal with submitting state at all.
You can get all values from the form manager with fm.values
. This includes all fields that are not disabled and where validation is
successful. If validation is not successful or the field is disabled its value is null
.
The type of fm.values
is MakeNullable<TFields>
where TFields
is your form fields. This is because all fields
can have a null
value even if they have a required
validator, because the user might not have filled out the form yet.
This can make it a bit difficult to transfer the data to an object where (some of) the fields are not nullable.
Say we have this DTO used by the backend:
type PersonDto = {
name: string;
age?: number;
};
then our form fields would be defined like:
type FormFields = {
name: string;
age: string;
};
// this could also be written like this
type FormFields = RemoveOptional<PersonDto>;
But the props on fm.values
are all nullable and can't be assigned to PersonDto
. It is a case of we know better than
the compiler, because we know all our validation rules have succeeded and therefor adhere to PersonDto
(assuming our
validation rules are correct).
You can solve it by using the as
keyword (which I normally don't allow in my code - same goes for any
) like this:
const dto = fm.values as PersonDto;
Another way which is a bit more verbose, but will throw meaningful errors should the assumption about the validation rules not hold:
const dto: PersonDto = {
name: ensureValue(fm.values.name),
age: fm.values.age ?? undefined
};
The helper function ensureValue
is from @ilbrando/utils
and ensures that a value is not null
or undefined
and
throws an error if this is not the case.