Awesome Forms with Solidjs

johncarroll

John Carroll

Posted on April 27, 2021

Awesome Forms with Solidjs

Editors note 6/7/22: I overhauled this article in response to library improvements.

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 3.6kb min zipped library to aid with user input forms: solid-forms. 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 { IFormControl, createFormControl } from 'solid-forms';

export const TextField: Component<{
  control: IFormControl<string>,
  label: string,
  placeholder?: string,
}> = (props) => {
  return (
    <label>
      <span class='input-label'>{props.label}</span>

      <input
        type="text"
        value={props.control.value}
        oninput={(e) => {
          props.control.markDirty(true);
          props.control.setValue(e.currentTarget.value || null);
        }}
        onblur={() => props.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, createFormGroup, createFormControl } from 'solid-forms';

const controlFactory = () => 
    createFormGroup({
      street: createFormControl<string | null>(null),
      city: createFormControl<string | null>(null),
      state: createFormControl<string | null>(null),
      zip: createFormControl<string | null>(null),
    });

export const AddressForm = withControl({
  controlFactory,
  component: (props) => {
    const controls = () => props.control.controls;

    return (
      <fieldset classList={{
        "is-valid": props.control.isValid,
        "is-invalid": !props.control.isValid,
        "is-touched": props.control.isTouched,
        "is-untouched": !props.control.isTouched,
        "is-dirty": props.control.isDirty,
        "is-clean": !props.control.isDirty,
      }}>
        <TextField label="Street" control={controls().street} />
        <TextField label="City" control={controls().city} />
        <TextField label="State" control={controls().state} />
        <TextField label="Postcode" control={controls().zip} />
      </fieldset>
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

Note that the address form is wrapped with withControl(), a higher order component. This streamlines the process of creating reusable form components.

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 = () => 
    createFormGroup({
      street: createFormControl<string | null>(null),
      city: createFormControl<string | null>(null),
      state: createFormControl<string | null>(null),
      zip: createFormControl<string | null>(null),
    });

export const AddressForm = withControl({
  controlFactory,
  component: (props) => {
    // 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 control={/* ... */} property to specify which FormControl on the parent should be connected with the child TextField.

const controls = () => props.control.controls;
// ...
<TextField label="Street" control={controls().street} />
<TextField label="City" control={controls().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.

Say we want to hook our AddressForm component into a larger form. That's also easy!

// factory for initializing the `MyLargerForm` `FormGroup`
const controlFactory = () => 
    createFormGroup({
      firstName: TextField.control(),
      address: AddressForm.control(),
    });

// the form component itself
export const MyLargerForm = withControl({
  controlFactory,
  component: (props) => {
    const controls = () => props.control.controls;

    // because we can
    const lastNameControl = createFormControl<string | null>(null);

    return (
      <form>
        <fieldset>
          <TextField label="First name" control={controls().firstName} />
          <TextField label="Last name" control={lastNameControl} />
        </fieldset>

        <AddressForm control={controls().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.

You may have noticed that we did something new in the controlFactory function.

const controlFactory = () => 
    createFormGroup({
      firstName: TextField.control(),
      address: AddressForm.control(),
    });
Enter fullscreen mode Exit fullscreen mode

We could have defined this controlFactory function the same way we defined the others (i.e. using createFormControl() and createFormGroup()), but withControl() helpfully adds the control's controlFactory function to a special control property on a component created using withControl(). This DRYs up our code and allows easily creating FormControl's for any components you create withControl().

So what if we want to listen to control changes in our MyLargerForm component? Well that's easy. For example, to listen to when any part of the form is touched, we can simply observe the isTouched property inside a Solidjs effect.

createEffect(() => {
  if (!props.control.isTouched) return;

  // do stuff...
});
Enter fullscreen mode Exit fullscreen mode

To listen to when the "firstName" control, specifically, is touched

createEffect(() => {
  if (!props.control.controls.firstName.isTouched) return;

  // do stuff...
});
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 { myCustomValidationService } from './my-validation-service';

export const MyLargerForm = withControl({
  // ...hiding the controlFactory boilerplate...
  component: (props) => {
    const control = () => props.control;
    const controls = () => control().controls;

    // This is a simplified example. In reality, you'd want
    // to debounce user input and only run async
    // validation when it made sense rather than on every
    // change. There are multiple ways to accomplish this
    // but none of them are specific to Solid Forms.
    createEffect(async () => {
      const firstName = controls().firstName;

      firstName.markPending(true);

      const response = await myCustomValidationService(
        firstName.value
      );

      if (response.errors) {
        firstName.setErrors({ validationFailed: true });
      } else {
        firstName.setErrors(null);
      }

      firstName.markPending(false);
    });

    const onsubmit (e) => {
      e.preventDefault();
      if (control().isPending || !control().isValid) return;

      // do stuff...
    };

    return (
      <form onsubmit={onsubmit}>
        <fieldset>
          <TextField label="First name" control={controls().firstName} />
          <TextField label="Last name" control={controls().lastName} />
        </fieldset>

        <AddressForm control={controls().address} />
      </form>
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

This is really just scratching the surface of what you can do with solid-forms. Check out the repo to read the documentation and learn more.

Check out the repo

💖 💪 🙅 🚩
johncarroll
John Carroll

Posted on April 27, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Awesome Forms with Solidjs
solidjs Awesome Forms with Solidjs

April 27, 2021