Yes, another form library for React. Why?
To take advantage of Typescript's advanced type system (v4.1) to give you more safety and a nice dev experience within your IDE.
Other reasons to use this library:
- Zero re-rendering of parent components
- Easy validation including async validators
- Standard form related state (valid, disabled, dirty, touched, error string)
- Arrays and nested forms
- Zero dependencies besides React
- MUI TextField binding
npm install @react-typed-forms/core
import { Finput, buildGroup, control } from "@react-typed-forms/core";
import { useState } from "react";
import React from "react";
interface SimpleForm {
firstName: string;
lastName: string;
}
const FormDef = buildGroup<SimpleForm>()({
firstName: "",
lastName: control("", (v) => (!v ? "Required field" : undefined)),
});
export default function SimpleExample() {
const [formState] = useState(FormDef);
const { fields } = formState;
const [formData, setFormData] = useState<SimpleForm>();
return (
<form
onSubmit={(e) => {
e.preventDefault();
setFormData(formState.toObject());
}}
>
<label>First Name</label>
<Finput id="firstName" type="text" state={fields.firstName} />
<label>Last Name *</label>
<Finput id="lastName" type="text" state={fields.lastName} />
<div>
<button id="submit">Validate and toObject()</button>
</div>
{formData && (
<pre className="my-2">{JSON.stringify(formData, undefined, 2)}</pre>
)}
</form>
);
}
In order to render your form you first need to define it's structure, default values and validators.
The function buildGroup<T>()
can be used to create a definition that matches the structure of your form data type. This comes in handy when you are creating forms based on types which are generated from a swagger or OpenAPI definition.
interface SimpleForm {
firstName: string;
lastName: string;
}
const FormDef = buildGroup<SimpleForm>()({
firstName: "",
lastName: control("", (v) => (!v ? "Required field" : undefined)),
});
control<V>(defaultValue)
is used to define a control which holds a single immutable value of type V. When used within buildGroup
the type will be inferred.
Instead of starting with a datatype and checking the form structure, you can also go with a form first approach:
const FormDef = groupControl({
firstName: "",
lastName: control("", (v) => (!v ? "Required field" : undefined)),
});
type SimpleForm = ValueTypeForControl<ControlType<typeof FormDef>>;
With the form defined you need to initialise it within your component by using the useState()
hook:
const [formState] = useState(FormDef);
This will return an instance of GroupControl
which has a fields
property which contains FormControl
instances.
The core library contains an <input>
renderer for FormControl
called Finput
which uses html5's custom validation feature to show errors.
return (
<div>
<Finput type="text" state={formState.fields.firstName} />
<Finput type="text" state={formState.fields.lastName} />
</div>
);
There is also a small library (@react-typed-forms/mui) which has some renderers for the MUI TextField
component.
Creating renderers for a FormControl
is very easy, it's a simple matter of using a hook function to register change listeners.
The easiest way is to just use useControlStateVersion()
to trigger a re-render whenever any change that needs to be re-rendered occurs.
The most low level change listener hook is useControlChangeEffect()
which just runs an effect function for the given change types.
Let's take a possible implementation Finput
implementation which uses both:
// Only allow strings and numbers
export type FinputProps = React.InputHTMLAttributes<HTMLInputElement> & {
state: FormControl<string | number>;
};
export function Finput({ state, ...others }: FinputProps) {
// Re-render on value or disabled state change
useControlStateVersion(state, ControlChange.Value | ControlChange.Disabled);
// Update the HTML5 custom validity whenever the error message is changed/cleared
useControlChangeEffect(
state,
(s) =>
(state.element as HTMLInputElement)?.setCustomValidity(state.error ?? ""),
ControlChange.Error
);
return (
<input
ref={(r) => {
state.element = r;
if (r) r.setCustomValidity(state.error ?? "");
}}
value={state.value}
disabled={state.disabled}
onChange={(e) => state.setValue(e.currentTarget.value)}
onBlur={() => state.setTouched(true)}
{...others}
/>
);
}
If you need complex validation which requires calling a web service, call useAsyncValidator()
with your validation callback which returns a Promise
with the error message (or null/undefined for valid). You also pass in a debounce time in milliseconds, so that you don't validate on each keypress.
If you need to re-render part of a component based on the value of a FormComponent
, use the userControlValue()
hook:
function UseControlValueComponent() {
const [titleField] = useState(control(""));
const title = useControlValue(titleField);
return (
<div>
Title: <Finput state={titleField} type="text" />
<br />
<h1>The title is {title}</h1>
</div>
);
}
A common scenario for forms is that you'd like to have a Save button which is disabled when the form is invalid.
import {useControlChangeEffect} from "@react-typed-forms/core";
const [formValid, setFormValid] = useState(formState.valid);
useControlChangeEffect(formState, () => setFormValid(formState.valid), ControlChange.Valid);
//...render form...
<button disabled={!formValid} onClick={() => save()}>Save</button>
useControlState()
handles the state updates for you so you could replace the above code with:
const formValid = useControlState(formState, (c) => c.valid, ControlChange.Valid);
NOTE: useControlValue
is just useControlState
using the value
.
The only downside to useControlState()
is that you will be re-rendering the whole component,
which usually won't matter if it's not too complicated but we can do better.
useControlStateComponent()
creates a component which takes a function that passes in the
computed state value and only renders that when it changes.
const FormValid = useControlStateComponent(formState, (c) => c.valid, ControlChange.Valid);
// ...render form...
<FormValid>
{(formValid) => (
<button disabled={!formValid} onClick={() => save()}>
Save
</button>
)}
</FormValid>