DEV Community

loading...

Awesome Forms with Solidjs

John Carroll
Updated on ・4 min read

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

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 to track that the value has been changed by the user. 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>
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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!

// factory for initializing the `MyLargerForm` `FormGroup`
const 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),        
      }),
    });

// the form component itself
export const MyLargerForm = withControl({
  controlFactory,
  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>
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

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 => {/* ... */})
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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, take } 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(
        take(1),
        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>
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

This is really just scratching the surface of what you can do with rx-controls-solid. Check out the repo to read the documentation and learn more or you can play around with the library using this codesandbox.

Check out the repo

Discussion (0)