Skip to content

create basic form

Michael Brandt edited this page Jun 18, 2024 · 2 revisions

On this page

Creating a form

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.

Define your form fields

You must define 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 allow null or undefined. You can use RemoveOptional from @ilbrando/utils to ensure this.

Define your form and create its state

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.

Get a form manager

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).

Show form fields

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 your FormFields this component handles.

The fieldName prop is typesafe so you can only choose props from FormFields and only props of a type the form control handles. This means when using FormNumberField you can only choose props of type number (age in the example).

The label prop is a prop of Joy FormLabel and is passed down to this component.

Basic form

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).

Validation

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.

Validation

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 use required("Some error message") instead of required().

Validation strategy

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.

All validation errors 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.

Handling submit

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 pass false to fm.getFormManager(fd, false) and not deal with submitting state at all.

How to get the values to submit

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.