-
Notifications
You must be signed in to change notification settings - Fork 0
Create Form Components
On this page
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 given 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.
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.
Instead of hard coding the error message, you can use the localization system.