This content originally appeared on DEV Community and was authored by John Carroll
I recently started falling in love with Solidjs, a javascript library that looks like React but is significantly faster and, dare I say, has a notably better API. Unlike React, Solidjs component functions are invoked only once when the component is initialized and then never again.
I decided to take advantage of Solidjs' strengths, and build a 9kb min zipped library to aid with user input forms: rx-controls-solid. Let's dive in and see what we can do (note, if you want an introduction to Solidjs, start here).
Let's create a simple TextField
component in typescript.
import { withControl, FormControl } from 'rx-controls-solid';
export const TextField = withControl((props) => {
// prop.control is static for the lifetime of the component
const control = props.control as FormControl<string | null>;
return (
<label>
<span class='input-label'>{props.label}</span>
<input
type="text"
value={control.value}
oninput={(e) => {
control.markDirty(true);
control.setValue(e.currentTarget.value || null);
}}
onblur={() => control.markTouched(true)}
placeholder={props.placeholder}
/>
</label>
);
});
This component tracks whether it has been touched
by a user (notice onblur
callback) and whether it has been changed by a user (oninput
). When a user changes the value, we mark the control as dirty
. We also have the ability to set a label on the input as well as a placeholder. Pretty straightforward stuff.
But text field's are rarely used in isolation. We want to build a component to collect some address information. This will involve asking for a Street
, City
, State
, and Postcode
. Lets use our TextField
component to create our AddressForm
.
import { withControl, FormGroup, FormControl } from 'rx-controls-solid';
import { toSignal } from './utils';
const controlFactory = () =>
new FormGroup({
street: new FormControl<string | null>(null),
city: new FormControl<string | null>(null),
state: new FormControl<string | null>(null),
zip: new FormControl<string | null>(null),
});
export const AddressForm = withControl({
controlFactory,
component: (props) => {
const control = props.control;
const isControlValid = toSignal(control.observe('valid'));
const isControlTouched = toSignal(control.observe('touched'));
const isControlDirty = toSignal(control.observe('dirty'));
return (
<fieldset classList={{
"is-valid": isControlValid(),
"is-invalid": !isControlValid(),
"is-touched": isControlTouched(),
"is-untouched": !isControlTouched(),
"is-dirty": isControlDirty(),
"is-clean": !isControlDirty(),
}}>
<TextField label="Street" controlName="street" />
<TextField label="City" controlName="city" />
<TextField label="State" controlName="state" />
<TextField label="Postcode" controlName="zip" />
</fieldset>
);
},
});
Note that the address form, itself, is also wrapped withControl()
. This allows the AddressForm
to also be used as a form component in a larger parent form.
We want our AddressForm
to use a FormGroup
control rather than the default FormControl
so we provide a controlFactory
function which initializes the control.
const controlFactory = () =>
new FormGroup({
street: new FormControl<string | null>(null),
city: new FormControl<string | null>(null),
state: new FormControl<string | null>(null),
zip: new FormControl<string | null>(null),
});
export const AddressForm = withControl({
controlFactory,
component: (props) => {
const control = props.control;
const isControlValid = toSignal(control.observe('valid'));
// continued...
All we needed to do to connect our AddressForm control to the TextField's
control was to use the controlName="street"
property to specify which FormControl on the parent should be connected with the child TextField.
<TextField label="Street" controlName="street" />
<TextField label="City" controlName="city" />
We also set the component up to apply css classes based on if the AddressForm
is valid/invalid, edited/unedit, and touched/untouched. There's actually a helper function to make applying css classes really easy, but for the sake of education I didn't use it for this example.
Say we want to hook our AddressForm component into a larger form. That's also easy!
export const MyLargerForm = withControl({
controlFactory: () =>
new FormGroup({
firstName: new FormControl<string | null>(null),
address: new FormGroup({
street: new FormControl<string | null>(null),
city: new FormControl<string | null>(null),
state: new FormControl<string | null>(null),
zip: new FormControl<string | null>(null),
}),
}),
component: (props) => {
const control = props.control;
// because we can
const lastNameControl = new FormControl<string | null>(null);
return (
<form>
<fieldset>
<TextField label="First name" controlName="firstName" />
<TextField label="Last name" control={lastNameControl} />
</fieldset>
<AddressForm controlName="address" />
</form>
);
},
});
And, with just a few steps, we have a very powerful, very composible set of form components. As changes happen to the TextField
components, those changes flow upwards and automatically update the parent FormGroup
components.
We can easily listen to any of these changes and respond to them via the parent.
For example, to listen to when any part of the form is touched, we can simply subscribe to touched
property state/changes.
control.observe('touched').subscribe(v => {/* ... */})
To listen to when the "firstName" control, specifically, is touched
// this is similar to control.controls.firstName.touched
control.observe('controls', 'firstName', 'touched')
// or
control.get('firstName').observe('touched')
Here's a more complex, advanced example: if we want to listen for value changes, debounce the rate of changes, perform validation, and mark the control
as pending while we wait for validation to complete, we can do the following. Note, when we set errors on the firstName
control, that will result in the "First name" TextField
being marked as invalid (score!).
import { interval } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { myCustomValidationService } from './my-validation-service';
export const MyLargerForm = withControl({
// ...hiding the controlFactory boilerplate...
component: (props) => {
const control = props.control;
const firstName = control.get('firstName');
const sub = control.observe('value', 'firstName').pipe(
tap(() => firstName.markPending(true)),
switchMap(v => interval(500).pipe(
switchMap(() => myCustomValidationService(v)),
tap(() => firstName.markPending(false)),
)),
).subscribe(result => {
if (result.errors) {
firstName.setErrors({ validationFailed: true });
} else {
firstName.setErrors(null);
}
});
const onsubmit (e) => {
e.preventDefault();
if (control.pending || control.invalid) return;
// do stuff...
};
onCleanup(() => sub.unsubscribe());
return (
<form onsubmit={onsubmit}>
<fieldset>
<TextField label="First name" controlName="firstName" />
<TextField label="Last name" control={lastNameControl} />
</fieldset>
<AddressForm controlName="address" />
</form>
);
},
});
This is really just scratching the surface of what you can do with rx-controls-solid
. I don't have much in the way of documentation at this point, but you can play around with the library using this codesandbox.
Check out the repo
This content originally appeared on DEV Community and was authored by John Carroll
John Carroll | Sciencx (2021-04-27T18:56:15+00:00) Awesome Forms with Solidjs. Retrieved from https://www.scien.cx/2021/04/27/awesome-forms-with-solidjs/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.