Skip to content

Create Form Components

ilbrando edited this page Jun 29, 2024 · 6 revisions

On this page

Create form components

Creating form components is required by simple-form, but it also comes with a lot of benefits, because they make it easy to create forms, the forms look and behave the same throughout the system, if something needs to be changed, it can be done in a single place, etc.

A form component should:

  • Be a controlled component which handles the value of a form field.
  • Shows some indication if the field is required.
  • Can show an optional error message

Lets create a form component for a vanilla HTML input.

The example below is a simple implementation using only builtin React components. I would recommend using a UI component library instead.

First we define the props for the component. The developer should be able to use it like a normal input so we take the original props.

type FormInputProps = DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;

Then we add a prop for the form manager. We need to use the TFields so we add this as a generic type parameter.

type FormInputProps<TFields> = DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {
  formManager: FormManager<TFields>;
};

Now we must add a prop for the field name. The field name must be a key of TFields which can be accomplished with keyof TFields, but we want to further narrow it down to a prop on TFields with the right type, so the developer only can choose fields of type string for this component. This can be done with the PropKeysOf helper from @ilbrando/utils.

type FormInputProps<TFields, TFieldName extends PropKeysOf<TFields, string>> = 
  DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {
    formManager: FormManager<TFields>;
    fieldName: TFieldName;
  };

This component operates on string and we are going to write this several times, so I like to create a type named FormValue and use this instead - it also makes it a little easier to copy the code when creating other form components.

type FormValue = string;

type FormInputProps<TFields, TFieldName extends PropKeysOf<TFields, FormValue>> = 
  DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {
    formManager: FormManager<TFields>;
    fieldName: TFieldName;
  };

The form manager handles the value of the component so the developer shouldn't have access to the value prop on the standard input component. So we omit this and the onChange event which is also handled by the form manager.

type FormInputProps<TFields, TFieldName extends PropKeysOf<TFields, FormValue>> = 
  OmitSafe<DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "value" | "onChange"> & {
    formManager: FormManager<TFields>;
    fieldName: TFieldName;
  };

I'm using OmitSafe from @ilbrando/utils because the builtin Omit is not type safe.

Now let's write the component. It must be generic in order to reference the props type. I always use const and arrow syntax instead of function, but because of the generic type parameters we can´t use arrow syntax.

export const FormInput = function <TFields, TFieldName extends PropKeysOf<TFields, string>>(props: FormInputProps<TFields, TFieldName>) {
  return <input {...props} />;
};

The component should show when the field is required and it should be able to show an error message if validation fails.

export const FormInput = function <TFields, TFieldName extends PropKeysOf<TFields, string>>(props: FormInputProps<TFields, TFieldName>) {
  const { formManager, fieldName, disabled, ...rest } = props;

  const editor = getEditor<TFields, FormValue>(formManager, fieldName, disabled);

  return <input value={editor.value ?? ""} {...rest} />;
};

The getEditor function is a helper from simple-form which is useful when creating form components. Notice we are decomposing props and extracting formManager, fieldName, disabled. The ...rest of the props are passed down to the builtin input component.

We then set the value prop on the input component. The value can be null and we want this to be an empty string in the component.

Now we need to handle changes (the user enters some text). We can use the editor for this.

<input value={editor.value ?? ""} onChange={e => editor.setFieldValue(emptyStringToNull(e.target.value))} {...rest} />

We must ensure an empty string is converted to null - emptyStringToNull does this for us.

Now let's show validation errors - this is a very simplified implementation, you should probably use a cusom component and no inline styling.

<div style={{ display: "flex", flexDirection: "column" }}>
  <input value={editor.value ?? ""} onChange={e => editor.setFieldValue(e.target.value)} {...rest} />
  {hasValue(editor.errorMessage) && <div style={{ color: "red" }}>{editor.errorMessage}</div>}
</div>

The component should show the user if the value is required. A common way is to put a * after the label, so lets add a label. We extends our props with an optional label.

type FormInputProps<TFields, TFieldName extends PropKeysOf<TFields, FormValue>> = 
  OmitSafe<DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "value" | "onChange"> & {
    formManager: FormManager<TFields>;
    fieldName: TFieldName;
    label?: string;
  };

And use it in our component.

export const FormInput = function <TFields, TFieldName extends PropKeysOf<TFields, string>>(props: FormInputProps<TFields, TFieldName>) {
  const { formManager, fieldName, disabled, label, ...rest } = props;

  const editor = getEditor<TFields, FormValue>(formManager, fieldName, disabled);

  return (
    <div style={{ display: "flex", flexDirection: "column" }}>
      {hasValue(label) && (
        <label>
          {label}
          {editor.isRequired && "*"}
        </label>
      )}
      <input value={editor.value ?? ""} onChange={e => editor.setFieldValue(emptyStringToNull(e.target.value))} {...rest} />
      {hasValue(editor.errorMessage) && <div style={{ color: "red" }}>{editor.errorMessage}</div>}
    </div>
  );
};

You may have noticed we also extract disabled from props, but doesn't seem to use it. We pass it to getEditor() and can use the isDisabled from the returned value. This isn't just the disabled value we passed in, but takes into account if the form is being submitted. To handle disabled state in our component we change it like this:

<input value={editor.value ?? ""} onChange={e => editor.setFieldValue(e.target.value)} disabled={editor.isDisabled} {...rest} />

Because we also takes the disabled prop set by the developer into account, the component can be permanently disabled if the developer writes <FormInput disabled /> and otherwise it will automatically be disabled when isSubmitting passed to getFormManager() is true.

Component errors

Form components show the error messages produced by the validators, but some components also produces their own errors. Take a form component for numeric values. The only valid values are number | null, but the user might enter some letters.

To handle this situation, the state for a field also includes a componentError which the component can set for an invalid value.

Below is an implementation of a FormNumberInput similar to the FormImput above but handling numeric values.

import { FormManager, getEditor } from "@ilbrando/simple-form";
import { OmitSafe, PropKeysOf, hasValue, hasValueAndNotEmptyString } from "@ilbrando/utils";
import { DetailedHTMLProps, useEffect, useState } from "react";

type FormValue = number;

type FormNumberInputProps<TFields, TFieldName extends PropKeysOf<TFields, FormValue>> = 
  OmitSafe<DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "value" | "onChange"> & {
    formManager: FormManager<TFields>;
    fieldName: TFieldName;
    label?: string;
  };

const isValidValue = (value: string) => /^[-]?(\d+)$/.test(value);

export const FormNumberInput = function <TFields, TFieldName extends PropKeysOf<TFields, FormValue>>(props: FormNumberInputProps<TFields, TFieldName>) {
  const { formManager, fieldName, disabled, label, ...rest } = props;

  const editor = getEditor<TFields, FormValue>(formManager, fieldName, disabled);

  const [textBoxValue, setTextBoxValue] = useState<string>(hasValue(editor.value) ? editor.value.toString() : "");

  useEffect(() => {
    setTextBoxValue(hasValue(editor.value) ? editor.value.toString() : "");
  }, [editor.value]);

  return (
    <div style={{ display: "flex", flexDirection: "column" }}>
      {hasValue(label) && (
        <label>
          {label}
          {editor.isRequired && "*"}
        </label>
      )}
      <input
        value={textBoxValue}
        onChange={e => {
          if (!hasValueAndNotEmptyString(e.target.value)) {
            setTextBoxValue("");
            editor.setFieldValue(null);
            return;
          }
          if (isValidValue(e.target.value)) {
            const parsedValue = parseInt(e.target.value);
            setTextBoxValue(e.target.value);
            editor.setFieldValue(parsedValue);
            return;
          }
          setTextBoxValue(e.target.value);
          editor.setFieldValue(editor.value, "Invalid value.");
        }}
        disabled={editor.isDisabled}
        {...rest}
      />
      {hasValue(editor.errorMessage) && <div style={{ color: "red" }}>{editor.errorMessage}</div>}
    </div>
  );
};

The idea is to have a local React state for the value in the textbox and only update the simple-form value when we have a valid value. The call to editor.setFieldValue(editor.value, "Invalid value.") includes an extra argument - the component error message.

Component error

Instead of hard coding the error message, you can use the localization system.