DEV Community

John Carroll
John Carroll

Posted on • Updated on

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

Top comments (5)

Collapse
 
quantuminformation profile image
Nikos

Why can't we just use native HTML5 validation instead of another form library?

Collapse
 
johncarroll profile image
John Carroll

You can. One of the nice aspects of solid is that you're working with real DOM elements. Any particular reason why you thought you couldn't?

Collapse
 
quantuminformation profile image
Nikos

even in the solid real world, there is so much boilerplate for html js validation, I never use it except html 5 validation, I hate writing validation code

Thread Thread
 
johncarroll profile image
John Carroll • Edited

Instead of looking at the real world example, take a look at the "Form Validation" example on the Solidjs webpage: solidjs.com/examples/forms. It makes use of the (probably less well known but very cool) directive's feature of solid. By extracting the HTML validation logic into a directive, it can be easily reused. Sounds like something along these lines might be exactly what you're looking for.

For my part, a big advantage of using a library like solid-forms is that it is not reliant upon the DOM and can be used both on the server and outside of solid equally well (e.g. I'm using this library in production in a react application via react-solid-state).

Thread Thread
 
xerullian profile image
Patrick Nolen

The directives in the Solidjs forms example are awesome! That example basically uses regular HTML. So simple.

Sure, you can't do that on the server, but I don't think forms should be handled on the server until they are submitted.