Skip to content

Forms library for SolidJS inspired by Angular's reactive forms.

License

Notifications You must be signed in to change notification settings

kajetansw/solar-forms

Repository files navigation

Solar Forms

Forms library for SolidJS inspired by Angular's reactive forms.

npm version downloads MIT license

import { createFormGroup, formGroup, Validators as V } from 'solar-forms';

const Registration = ({ onSubmit }: Props) => {
  const fg = createFormGroup({
    email: ['', { validators: [V.required] }],
    name: '',
    password: ['', { validators: [V.required] }],
    acceptTerms: [false, { validators: [V.is(true)] }]
  });
  const [form, setForm] = fg.value;
  const validAll = fg.validAll;

  return (
    <>
      <form use:formGroup={fg}>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" formControlName="email" />
        
        <label htmlFor="name">Name (optional)</label>
        <input id="name" type="text" formControlName="name" />
        
        <label htmlFor="password">Password</label>
        <input id="password" type="password" formControlName="password" />
        
        <label htmlFor="acceptTerms">Accept terms</label>
        <input id="acceptTerms" type="checkbox" formControlName="acceptTerms" />
        
        <button type="submit" disabled={!validAll()} onClick={onSubmit}>
          Submit
        </button>
      </form>
    </>
  );
};

About

Solar Forms allows you to create reactive and type-safe state for your form controls. It lets you take over form controls and access key information like control's current value, whether it's disabled, valid, etc. as SolidJS signals. Form controls can also be pre-configured with validator functions, ensuring your form won't be marked as valid unless all data is correct.

⚠️ This library is still in very early stages of development. It is not encouraged to use it in production applications. Although we encourage you to try it out and give some feedback!

Features

  • Create form group as a set of related controls that you can manage.
  • Use form control properties like value, disabled, dirty and touched.
  • Use form group properties like disabledAll, dirtyAll and touchedAll.
  • Pre-configure form controls with built-in or custom validator functions to ensure you have all information you need before the form is submitted.
  • Check if a single form control or an entire form group is valid with valid and validAll properties.
  • Access validation errors with errors form control property.
  • Access all form group and form control properties as SolidJS signals.
  • Create nested form control structures.

Installation

# using npm
npm install solar-forms

# using yarn
yarn add solar-forms

If you encounter any issues when setting up Solar Forms, try consulting our FAQ section!

Documentation

Online examples

Getting started

Creating form group

One of main elements of Solar Forms is createFormGroup function, that lets you instantiate form group that you can use to manage your form:

const fg = createFormGroup({
  // ➡️ Registering form control under `firstName` name 
  firstName: 'John',
});

createFormGroup has a single argument and that is an object representing structure of your form. In the case above we define the firstName form control and we set an initial value to it.

Later you'll see just how much we can expand your form group and which properties of it we'll be able to manage!

Binding our form group to the form element using formGroup directive

Second of the main concepts of Solar Forms is the formGroup SolidJS directive, that allows you to bind your form group to your form HTML element:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Form control named `firstName`
  firstName: 'John',
});

// 2️⃣ Binding form group to the form using `use:formGroup`
return (
  <form use:formGroup={fg}>
    <label htmlFor="firstName">First name</label>
    {/* 3️⃣ Binding form control to the input element using `formControlName` property */}
    <input id="firstName" type="text" formControlName="firstName" />
  </form>
);

Under the hood, the formGroup directive sets up the entire set of SolidJS signals and initiates form control properties.

Still, our form group needs to know which form controls belong to which form inputs. This is why we bind those together by assigning formControlName property to form inputs with the same name, as defined for the form control.

Accessing form control values at any time

After you set up your form group the way shown above, you can access your form control values using the value signal:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
});
// ➡️ Accessing form group `value` signal
const [form, setForm] = fg.value;

return (
  <form use:formGroup={fg}>
    <label htmlFor="firstName">First name</label>
    <input id="firstName" type="text" formControlName="firstName" />
  </form>
);

The value signal contains tuple of reactive value for our form group and a setter function. You'll see this pattern along the way of learning about the rest of form control properties:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
});
const [form, setForm] = fg.value;

// 1️⃣ Accessing form groups's reactive value
const logForm = () => console.log(JSON.stringify(form()));

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      <input id="firstName" type="text" formControlName="firstName" />
    </form>

    {/* 2️⃣ Clicking this logs "{"firstName":"John"}" */}
    <button onClick={logForm}>
      Log form value
    </button>
  </>
);

A setter function allows us to set the form controls' values at will:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
});
const [form, setForm] = fg.value;

// ➡️ Changing form control value
const changeName = () => setForm({ ...form(), firstName: 'Tom' });

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      <input id="firstName" type="text" formControlName="firstName" />
    </form>

    <button onClick={changeName}>
      Change first name
    </button>
  </>
);

As an argument, setter functions for form controls accept new form group value object, or a setter callback:

const fg = createFormGroup({
  firstName: 'John',
});
const [form, setForm] = fg.value;

// 1️⃣ Update form value by passing value object
const changeName1 = () => setForm({ ...form(), firstName: 'Tom' });
// 2️⃣ Update form value by passing setter callback
const changeName2 = () => setForm(f => ({ ...f, firstName: 'Tom' }));

Managing disabled form control property

When defining your form group, you can mark individual form controls as disabled by default. You can do that by passing a tuple of default value and form control config, instead of a single value:

const fg = createFormGroup({
  // With this, the `firstName` form control is marked as disabled by default
  firstName: ['John', { disabled: true }],
});

As you may have already realised, if you do not implicitly mark control as disabled, only by passing only a default value, the form control is enabled by default:

const fg = createFormGroup({
  // The `firstName` form control is set as enabled by default
  firstName: 'John',
});

You can access the disabled state of the form controls by using a specific form group signal:

const fg = createFormGroup({
  firstName: ['John', { disabled: true }],
});
// Accessing the reactive enabled/disabled state of form controls 
// and a setter function under `disabled` property
const [disabled, setDisabled] = fg.disabled;

Under the hood, the disabled state is bound to your form elements, so that any change to form control state is reflected in the UI:

// Component definition

const fg = createFormGroup({
  firstName: ['John', { disabled: true }],
});
const [disabled, setDisabled] = fg.disabled;

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      {/* ➡️ This form element is now disabled */}
      <input id="firstName" type="text" formControlName="firstName" />
    </form>
  </>
);

Also, any update to the disabled form control state is reflected in the UI as well:

// Component definition

const fg = createFormGroup({
  firstName: ['John', { disabled: true }],
});
const [disabled, setDisabled] = fg.disabled;

const enableFirstName = () => setDisabled(d => ({ ...d, firstName: false }));

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      {/* 1️⃣ This form element is disabled on initialization */}
      <input id="firstName" type="text" formControlName="firstName" />
    </form>

    {/* 2️⃣ After clicking this button, the `firstName` form input becomes enabled */}
    <button onClick={enableFirstName}>
      Enable first name
    </button>
  </>
);

Managing disabledAll form group property

If you value information on whether your entire form is disabled (meaning, every form input element is disabled), you can use the disabledAll form group property, which also is a SolidJS signal:

const fg = createFormGroup({
  firstName: ['John', { disabled: true }],
  lastName: 'Smith',
});
// Accessing information on whether the entire form is disabled or enabled
const [disabledAll, setDisabledAll] = fg.disabledAll;

This aggregates all disabled form control properties under one boolean value:

const fg = createFormGroup({
  firstName: ['John', { disabled: true }],
  lastName: 'Smith',
});
const [disabledAll, setDisabledAll] = fg.disabledAll;

// Logs `false`, because the `lastName` form control is enabled
console.log(disabledAll());

You can also set your entire form as enabled or disabled using the setDisabledAll setter function:

// Component definition

const fg = createFormGroup({
  firstName: ['John', { disabled: true }],
  lastName: 'Smith',
});
const [disabledAll, setDisabledAll] = fg.disabledAll;

// 1️⃣ Set all form elements as disabled with this single call
const disableForm = () => setDisabledAll(true);

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      <input id="firstName" type="text" formControlName="firstName" />
      <label htmlFor="lastName">Last name</label>
      <input id="lastName" type="text" formControlName="lastName" />
    </form>

    {/* 2️⃣ After clicking this button, all form elements become disabled */}
    <button onClick={disableForm}>
      Disable form
    </button>
  </>
);

⚠️ As a contrary to value or disabled form control properties, disabledAll is a form group property, as it represents state of the entire form, not its individual elements.

As an argument, the setter function for disabledAll property accepts boolean or a setter callback:

const fg = createFormGroup({
  firstName: 'John',
});
const [disabledAll, setDisabledAll] = fg.disabledAll;

// 1️⃣ Update `disabledAll` state by passing boolean
const disableForm1 = () => setDisabledAll(true);
// 2️⃣ Update `disabledAll` state by passing setter callback
const disableForm2 = () => setDisabledAll(d => !d);

Managing dirty form control property

After you define your form group, it tracks whether the user has already changed the form input value from UI. That's what the dirty form control property is for:

const fg = createFormGroup({
  firstName: 'John',
});
// Accessing the reactive `dirty` state of form controls 
// and a setter function under the `dirty` signal
const [dirty, setDirty] = fg.dirty;

Under the hood, every form control is marked as "pristine" (as an opposite to "dirty") on initialization , so all dirty values for form controls are false by default:

const fg = createFormGroup({
  firstName: 'John',
});
const [dirty, setDirty] = fg.dirty;

// 1️⃣ Logs `false`, as user did not update the form input yet
console.log(dirty().firstName);

Whenever the user changes the form input value from UI, the dirty property for the form control is set to true:

// Component definition

const fg = createFormGroup({
  firstName: '',
});
const [dirty, setDirty] = fg.dirty;

const logDirtyForFirstName = () => console.log(dirty().firstName);

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      {/* 1️⃣ Imagine user updating their first name from UI */}
      <input id="firstName" type="text" formControlName="firstName" />
    </form>

    {/* 2️⃣ After the update, clicking this button logs `true` */}
    <button onClick={logDirtyForFirstName}>
      Log
    </button>
  </>
);

You can also mark your form controls as "dirty" or "pristine" (as an opposite to "dirty") programmatically as well:

// Component definition

const fg = createFormGroup({
  firstName: '',
});
const [dirty, setDirty] = fg.dirty;

const markFirstNameAsPristine = () => setDirty(d => ({ ...d, firstName: false }));
const logDirtyForFirstName = () => console.log(dirty().firstName);

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      {/* 1️⃣ Imagine user updates their first name from UI */}
      <input id="firstName" type="text" formControlName="firstName" />
    </form>

    {/* 2️⃣ After the update, clicking this button marks the `firstName` */}
    {/* form control as "pristine" again */}
    <button onClick={markFirstNameAsPristine}>
      Change dirty
    </button>
    {/* 3️⃣ Clicking this button logs `false`, as we set the property to it ourselves */}
    <button onClick={logDirtyForFirstName}>
      Log
    </button>
  </>
);

Managing dirtyAll form group property

Similarly to disabledAll, there is a dirtyAll form group property aggregating all form controls' dirty properties under one boolean value. dirtyAll holds information whether the entire form (meaning every form control in the form) is "dirty":

const fg = createFormGroup({
  firstName: 'John',
  lastName: 'Smith',
});
// 1️⃣ Accessing information on whether the entire form is dirty
const [dirtyAll, setDirtyAll] = fg.dirtyAll;

// 2️⃣ Logs `false`, because all form controls weren't updated from UI
console.log(dirtyAll());

You can also set your entire form as "dirty" or "pristine" (as an opposite to "dirty") using the setDirtyAll setter function:

// Component definition

const fg = createFormGroup({
  firstName: 'Johm',
  lastName: 'Smith',
});
const [dirtyAll, setDirtyAll] = fg.dirtyAll;

// 1️⃣ Sets all form elements as "dirty" with a single call
const changeAllToDirty = () => setDirtyAll(true);
const logDirtyAll = () => console.log(dirtyAll());

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      <input id="firstName" type="text" formControlName="firstName" />
      <label htmlFor="lastName">Last name</label>
      <input id="lastName" type="text" formControlName="lastName" />
    </form>

    {/* 2️⃣ After clicking this button, all form elements are marked as "dirty" */}
    <button onClick={changeAllToDirty}>
      Change all to dirty
    </button>

    {/* 3️⃣ After the update, clicking this button logs `true` */}
    <button onClick={logDirtyAll}>
      Log dirtyAll
    </button>
  </>
);

As an argument, the setter function for dirtyAll property accepts boolean or a setter callback:

const fg = createFormGroup({
  firstName: 'John',
});
const [dirtyAll, setDirtyAll] = fg.dirtyAll;

// 1️⃣ Update `dirtyAll` state by passing boolean
const markAllAsDirty1 = () => setDirtyAll(true);
// 2️⃣ Update `dirtyAll` state by passing setter callback
const markAllAsDirty2 = () => setDirtyAll(d => !d);

Managing touched form control property

After you define your form group, it tracks whether the user has already triggered a blur event on the form input value. With this you can track whether the user has already been focused on a specific form input element, or not. That's what the touched form control property is for:

const fg = createFormGroup({
  firstName: 'John',
});
// 1️⃣ Accessing the reactive "touched" state of form controls 
// and a setter function under the `touched` signal
const [touched, setTouched] = fg.touched;

Under the hood, every form control is marked as "untouched" on initialization, so all touched values for form controls are false by default:

const fg = createFormGroup({
  firstName: 'John',
});
const [touched, setTouched] = fg.touched;

// 1️⃣ Logs `false`, as there hasn't been a "blur" event triggered yet on the `firstName` form input 
console.log(touched().firstName);

Whenever user switches from the specific form control to another (triggering the "blur" event), the touched property for the form control is set to true:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
  lastName: 'John',
});
const [touched, setTouched] = fg.touched;

const logTouchedForFirstName = () => console.log(touched().firstName);

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      {/* 1️⃣ Imagine user switching from this form input... */}
      <input id="firstName" type="text" formControlName="firstName" />
      <label htmlFor="lastName">Last name</label>
      {/* 2️⃣ ... to this one */}
      <input id="lastName" type="text" formControlName="lastName" />
    </form>

    {/* 3️⃣ After the "blur" event has been triggered, clicking this button logs `true` */}
    <button onClick={logTouchedForFirstName}>
      Log
    </button>
  </>
);

You can also mark your form controls as "touched" or "untouched" programmatically:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
  lastName: 'John',
});
const [touched, setTouched] = fg.touched;

const changeTouchedForFirstName = () => setTouched(d => ({ ...d, firstName: false }));
const logTouchedForFirstName = () => console.log(touched().firstName);

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      {/* 1️⃣ Imagine user switching from this form input... */}
      <input id="firstName" type="text" formControlName="firstName" />
      <label htmlFor="lastName">Last name</label>
      {/* 2️⃣ ... to this one */}
      <input id="lastName" type="text" formControlName="lastName" />
    </form>

    {/* 3️⃣ After the "blur" event has been triggered, clicking this */}
    {/* switches `touched` to `false` again */}
    <button onClick={changeTouchedForFirstName}>
      Change touched
    </button>
    {/* 4️⃣ Clicking this button logs `false`, as we set the property to it ourselves */}
    <button onClick={logTouchedForFirstName}>
      Log
    </button>
  </>
);

Managing touchedAll form group property

Similarly to disabledAll and dirtyAll, there is a touchedAll form group property aggregating all form controls' touched properties under one boolean value. touchedAll holds information whether the entire form (meaning every form control in the form) is "touched":

const fg = createFormGroup({
  firstName: 'Johm',
  lastName: 'Smith',
});
// 1️⃣ Accessing information on whether the entire form is "touched"
const [touchedAll, setTouchedAll] = fg.touchedAll;

// 2️⃣ Logs `false`, because "blur" event wasn't triggered for all form inputs
console.log(touchedAll());

You can also mark your entire form as "touched" or "untouched" using the setTouchedAll setter function:

// Component definition

const fg = createFormGroup({
  firstName: 'Johm',
  lastName: 'Smith',
});
const [touchedAll, setTouchedAll] = fg.touchedAll;

// 1️⃣ Sets all form elements as "touched" with a single call
const changeAllToTouched = () => setTouchedAll(true);
const logTouchedAll = () => console.log(touchedAll());

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="firstName">First name</label>
      <input id="firstName" type="text" formControlName="firstName" />
      <label htmlFor="lastName">Last name</label>
      <input id="lastName" type="text" formControlName="lastName" />
    </form>

    {/* 2️⃣ After clicking this button, all form elements are marked as "touched" */}
    <button onClick={changeAllToTouched}>
      Change all to touched
    </button>

    {/* 3️⃣ After the update, clicking this button logs `true` */}
    <button onClick={logTouchedAll}>
      Log touchedAll
    </button>
  </>
);

As an argument, the setter function for touchedAll property accepts boolean or a setter callback:

const fg = createFormGroup({
  firstName: 'John',
});
const [touchedAll, setTouchedAll] = fg.touchedAll;

// 1️⃣ Update `touchedAll` state by passing boolean
const markAllAsDirty1 = () => setTouchedAll(true);
// 2️⃣ Update `touchedAll` state by passing setter callback
const markAllAsDirty2 = () => setTouchedAll(d => !d);

Validating form controls

Setting up form control validators

As you've probably worked with forms before (as a developer or a user), you realised that not every user input should be valid. We do not accept emails with wrong format, empty passwords, terms checkbox not ticked or dates of birth that make you over 200 years old.

With Solar Forms you can take control over your user's input and define how your form controls should be validated. You can do that by using built-in or custom validator functions:

import { FormControl, ValidatorFn } from 'solar-forms';

const required: ValidatorFn = (formControl: FormControl) => 
  formControl.value ? null : { required: true };

This is a very simple example of how to define custom required validator function for your form controls. Here we are checking whether the value is falsy (but remember that 0 is also falsy!) and if it is, we return the record with validation error. If the value is valid, we return null, meaning there are no validation errors.

FormControl and ValidatorFn types seem important here, so let's take a look at their definitions:

export interface FormControl {
  value: string | number | boolean | Date | null;
  disabled: boolean;
  touched: boolean;
  dirty: boolean;
}

export interface ValidatorFn {
  (control: FormControl): ValidationErrors | null;
}

export type ValidationErrors = {
  [key: string]: unknown;
};

As you can see, when defining a validator function, you can use various data about your form control that may be important to you: its current value and whether it is disabled, touched or dirty.

With validator function defined, you can pre-configure your form controls with it when creating your form group:

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  // Adding validator(s) to your form control
  password: ['', { validators: [required] }],
});

As you see, you can add validator(s) to your form control by passing a list of validator functions to the config object under the validators key.

Accessing the valid form control property

After you define your form group, it tracks whether the form control values are valid or invalid. That's what the valid form control property is for:

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  password: ['', { validators: [required] }],
});
// Accessing the reactive "valid" state of form controls 
const valid = fg.valid;

Under the hood, every form control is marked as valid or invalid on initialization, based on whether the form control value passes the all validator functions.

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  password: ['', { validators: [required] }],
});
const valid = fg.valid;

// Logs `false` as the value is required and it's an empty string on initialization
console.log(valid().password);

After changing form control values, (either with UI or using value setter functions), the validation functions are run again against form control values and the valid state is updated accordingly.

// Component definition

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  password: ['', { validators: [required] }],
});
const valid = fg.valid;

const logValidForPassword = () => console.log(valid().password);

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="password">Password</label>
      {/* 1️⃣ Imagine user typing their password */}
      <input id="password" type="password" formControlName="password" />
    </form>

    {/* 2️⃣ After user types their password, clicking this button logs `true` */}
    <button onClick={logValidForPassword}>
      Log
    </button>
  </>
);

Accessing the validAll form group property

valid form control property also has the corresponding form group property - validAll. It represents aggregated data on all form controls being valid or not. That means you can use validAll accessor to check whether the whole form is valid or not:

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  name: '',
  password: ['', { validators: [required] }],
});
// 1️⃣ Accessing the reactive "validAll" state of form controls 
const validAll = fg.validAll;

// 2️⃣ Logs `false` as one of form control values is required and it's 
// an empty string on initialization
console.log(validAll());

When all form controls are valid (all form controls' validator functions pass), validAll returns true as well:

// Component definition

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  name: '',
  password: ['', { validators: [required] }],
});
const validAll = fg.validAll;

const logValidAll = () => console.log(validAll());

return (
  <>
    <form use:formGroup={fg}>
      <label htmlFor="name">Name (optional)</label>
      <input id="name" type="text" formControlName="name" />
      <label htmlFor="password">Password</label>
      {/* 1️⃣ Imagine user typing their password */}
      <input id="password" type="password" formControlName="password" />
    </form>

    {/* 2️⃣ After user types their password, clicking this button logs `true` */}
    <button onClick={logValidAll}>
      Log
    </button>
  </>
);

Accessing validation errors

At the same time, form group tracks all validation errors at a given time with the errors form control property:

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  password: ['', { validators: [required] }],
});
// Accessing the reactive "errors" state of form controls 
const errors = fg.errors;

On initialization and on every form value update validator functions are run against form control values and the errors form control property is updated accordingly:

const required = (formControl) =>
  formControl.value ? null : { required: true };

const fg = createFormGroup({
  name: '',
  password: ['', { validators: [required] }],
});
const errors = fg.errors;

// 1️⃣ Logs "{"required":"This is required!"}" as the value is required 
// and it's an empty string on initialization
console.log(JSON.stringify(errors().password));

// 2️⃣ Logs "null" as the value has no validators, so it's valid by default 
console.log(JSON.stringify(errors().name));

error form control property holds an object with all validation errors aggregated under one object.

⚠️ Because of the fact, that error form control property holds an object with all validation errors aggregated under one record, it is advised to name keys for your validation errors object in a unique way when creating your custom validator functions.

Doing otherwise may result in overwriting your keys inside the ValidationErrors record.

Built-in validators

As you've learned, form group accepts list of validators that match the ValidatorFn interface. This means you can write custom validators for your own use cases.

As an alternative, you can use built-in validators provided by Solar Forms. Here's a brief introduction:

required validator

Validator that requires the control have a non-empty value (null and'' are treated as empty values).

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  firstName: ['', { validators: [V.required] }],
  lastName: ['Smith', { validators: [V.required] }],
});
const errors = fg.errors;

// ➡️ Logs `{ required: true }`
console.log(errors().firstName);
// ➡️ Logs `null`
console.log(errors().lastName);
min validator

Validator that requires the control's value to be greater than or equal to the provided number.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  minorAge: [16, { validators: [V.min(21)] }],
  adultAge: [30, { validators: [V.min(21)] }],
});
const errors = fg.errors;

// ➡️ Logs `{ min: true }`
console.log(errors().minorAge);
// ➡️ Logs `null`
console.log(errors().adultAge);
max validator

Validator that requires the control's value to be less than or equal to the provided number.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  invalidAmount: [30, { validators: [V.max(10)] }],
  validAmount: [5, { validators: [V.max(10)] }],
});
const errors = fg.errors;

// ➡️ Logs `{ max: true }`
console.log(errors().invalidAmount);
// ➡️ Logs `null`
console.log(errors().validAmount);
minLength validator

Validator that requires the length of the control's string-based value's length to be greater than or equal to the provided minimum length.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  invalidPassword: ['grfr', { validators: [V.minLength(8)] }],
  validPassword: ['wdnaw#@!udnwahe3w@#$@!', { validators: [V.minLength(8)] }],
});
const errors = fg.errors;

// ➡️ Logs `{ minLength: true }`
console.log(errors().invalidPassword);
// ➡️ Logs `null`
console.log(errors().validPassword);
maxLength validator

Validator that requires the length of the control's string-based value's length to be lower than or equal to the provided maximum length.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  invalidInput: ['qwertyuiopasdfg', { validators: [V.maxLength(10)] }],
  validInput: ['qwerty', { validators: [V.maxLength(10)] }],
});
const errors = fg.errors;

// ➡️ Logs `{ maxLength: true }`
console.log(errors().invalidInput);
// ➡️ Logs `null`
console.log(errors().validInput);
is validator

Validator that requires the value of the form control to be equal to the provided value.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  invalidCaptcha: ['q1q1q1', { validators: [V.is('q1w2e3')] }],
  validCaptcha: ['q1w2e3', { validators: [V.is('q1w2e3')] }],
  nonAcceptedTerms: [false, { validators: [V.is(true)] }],
  acceptedTerms: [true, { validators: [V.is(true)] }],
});
const errors = fg.errors;

// ➡️ Logs `{ is: true }`
console.log(errors().invalidCaptcha);
// ➡️ Logs `null`
console.log(errors().validCaptcha);
// ➡️ Logs `{ is: true }`
console.log(errors().nonAcceptedTerms);
// ➡️ Logs `null`
console.log(errors().acceptedTerms);
isAnyOf validator

Validator that requires the value of the form control to be equal to one of provided values.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  invalidCountry: ['Poland', { validators: [V.isAnyOf(['Spain', 'France', 'Germany'])] }],
  validCountry: ['Spain', { validators: [V.isAnyOf(['Spain', 'France', 'Germany'])] }],
});
const errors = fg.errors;

// ➡️ Logs `{ isAnyOf: true }`
console.log(errors().invalidCountry);
// ➡️ Logs `null`
console.log(errors().validCountry);
email validator

Validator that requires the value of the form control to have a valid email format.

import { createFormGroup, Validators as V } from 'solar-forms';

const fg = createFormGroup({
  invalidEmail: ['test', { validators: [V.email] }],
  validEmail: ['[email protected]', { validators: [V.email] }],
});
const errors = fg.errors;

// ➡️ Logs `{ email: true }`
console.log(errors().invalidEmail);
// ➡️ Logs `null`
console.log(errors().validEmail);
pattern validator

Validator that requires the value of the form control to have a format matching provided regular expression.

import { createFormGroup, Validators as V } from 'solar-forms';

const regexp = /^([0-1][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$/gm;
const fg = createFormGroup({
  invalidHour: ['test', { validators: [V.pattern(regexp)] }],
  validHour: ['12:00:00', { validators: [V.pattern(regexp)] }],
});
const errors = fg.errors;

// ➡️ Logs `{ pattern: { requiredPattern: ..., actualValue: 'test' } }`
console.log(errors().invalidHour);
// ➡️ Logs `null`
console.log(errors().validHour);

Binding form controls to different types of input elements

There are a lot of possible types for an HTML input element and Solar Forms allows you to work with HTML input elements of every major type:

  • text
  • email
  • password
  • tel
  • url
  • number
  • range
  • date
  • datetime-local
  • time
  • checkbox
  • radio

Type of text

This is the most basic input element - a string-based input element type. Accordingly, you can define your form control's default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as string
  name: '',
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="name">Name</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="name" type="text" formControlName="name" />
  </form>
);

Type of email

Another string-based input element type. For the corresponding form control you can define the default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as string
  email: '',
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="email">Email</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="email" type="email" formControlName="email" />
  </form>
);

Type of password

Another string-based input element type. For the corresponding form control you can define the default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as string
  password: '',
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="password">Password</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="password" type="password" formControlName="password" />
  </form>
);

Type of tel

Another string-based input element type. For the corresponding form control you can define the default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as string
  tel: '',
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="tel">Tel</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="tel" type="tel" formControlName="tel" />
  </form>
);

Type of url

Another string-based input element type. For the corresponding form control you can define the default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as string
  url: '',
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="url">URL</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="url" type="url" formControlName="url" />
  </form>
);

Type of number

The most basic number-based type of input element. In this case, for the corresponding form control you can define the default value as number or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as number
  age: 0,
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="age">URL</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="age" type="number" formControlName="age" />
  </form>
);

Type of range

Another number-based type of input element. For the corresponding form control you can define the default value as number or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here as number
  skillLevel: 0,
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="skillLevel">Skill level</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="skillLevel" type="range" formControlName="skillLevel" />
  </form>
);

Type of date

A date-based type of input element. In this case, for the corresponding form control you can define the default value as Date object, string, number or null:

⚠️ If you wish to bind string values to the date form control, remember about using proper date text formats.

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default values are set here
  dateDate: new Date(),
  dateString: new Date().toISOString().split('T')[0],
  dateNumber: new Date().getTime(),
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="dateDate">Date [Date]</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="dateDate" type="date" formControlName="dateDate" />
    
    <label htmlFor="dateString">Date [string]</label>
    <input id="dateString" type="date" formControlName="dateString" />
    
    <label htmlFor="dateNumber">Date [number]</label>
    <input id="dateNumber" type="date" formControlName="dateNumber" />
  </form>
);

Type of datetime-local

A date-based type of input element. In this case, for the corresponding form control you can define the default value as string, number or null:

⚠️ If you wish to bind string values to the datetime-local form control, remember about using proper date text formats.

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default values are set here
  dateString: new Date().toISOString().split('.')[0],
  dateNumber: new Date().getTime(),
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="dateString">Date [string]</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="dateString" type="datetime-local" formControlName="dateString" />
    
    <label htmlFor="dateNumber">Date [number]</label>
    <input id="dateNumber" type="datetime-local" formControlName="dateNumber" />
  </form>
);

Type of time

A time-based type of input element. In this case, for the corresponding form control you can define the default value as Date, string, number or null:

⚠️ If you wish to bind string values to the time form control, remember about using proper time text formats.

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default values are set here
  timeDate: new Date(),
  timeString: '00:00:00',
  timeNumber: new Date().getTime(),
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="timeDate">Date [Date]</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="timeDate" type="time" formControlName="timeDate" />
    
    <label htmlFor="timeString">Date [string]</label>
    <input id="timeString" type="time" formControlName="timeString" />
    
    <label htmlFor="timeNumber">Date [number]</label>
    <input id="timeNumber" type="time" formControlName="timeNumber" />
  </form>
);

Type of checkbox

A boolean-based type of input element. For the corresponding form control you can define the default value as boolean or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default values are set here
  acceptTerms: false,
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="acceptTerms">Date [Date]</label>
    {/* 2️⃣ Here we define the `type` attribute of the input element */}
    <input id="acceptTerms" type="checkbox" formControlName="acceptTerms" />
  </form>
);

Type of radio

A string-based type of input element, where you can choose one of pre-defined set of options. For the corresponding form control you can define the default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here
  // 2️⃣ Imagine you can have 3 options here: "engineering", "testing" and "product"
  team: null,
});

return (
  <form use:formGroup={fg}>
    {/* 3️⃣ Choosing one of 3 options sets form control value to */}
    {/* a value assigned to specific radio input */}
    <input type="radio" id="radioEngineering" name="team" value="engineering" formControlName="team" />
    <label htmlFor="radioEngineering">Engineering</label>
    <input type="radio" id="radioProduct" name="team" value="product" formControlName="team" />
    <label htmlFor="radioProduct">Product</label>
    <input type="radio" id="radioTesting" name="team" value="testing" formControlName="team" />
    <label htmlFor="radioTesting">Testing</label>
  </form>
);

Binding form controls to <select> element

You can bind string values to the <select> element. You can change value of the element by choosing one of the predefined options:

type CountryOption = '' | 'Poland' | 'Spain' | 'Germany';

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here
  country: '' as CountryOption,
});

return (
  <form use:formGroup={fg}>
    {/* 2️⃣ Choosing one of available options sets form control value to */}
    {/* a value assigned to specific <option> element */}
    <label htmlFor="country-select">
      Country
      <select name="country" id="country-select" formControlName="country">
        <option value="">--Please choose an option--</option>
        <option value="Poland">Poland</option>
        <option value="Spain">Spain</option>
        <option value="Germany">Germany</option>
      </select>
    </label>
  </form>
);

Binding form controls to <textarea> element

textarea element is a string-based form control. You can define your form control's default value as string or null:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Default value is set here
  bio: '',
});

return (
  <form use:formGroup={fg}>
    <label htmlFor="bio">Bio</label>
    {/* 2️⃣ Here we bind form control to form group */}
    <textarea name="bio" id="bio" formControlName="bio" />
  </form>
);

Form control errors

To ensure that the form group defined by us matches the form structure in our template, some additional runtime checks were implemented.

Form control name does not match any key from form group

In case we'd made a mistake while connecting a form group key with the form input element using formControlName HTML attribute, we would get a runtime error informing us of the mistake:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
});

return (
  <>
    <form use:formGroup={fg}>
      <label for="firstName">First name</label>
      <input id="firstName" type="text" formControlName="company" />
    </form>
  </>
);

This one results in throwing a custom FormControlInvalidKeyError error with a message:

"company" form control name does not match any key from the form group.

Form control type does not match the type of an input element

In case we'd made a mistake when initializing the value of a form control in our form group, that wasn't supposed to be used with a given HTML element, we would get a runtime error informing us of the mistake:

// Component definition

const fg = createFormGroup({
  // 1️⃣ Here we initialize `firstName` form control value as `string`
  firstName: 'John',
});

return (
  <>
    <form use:formGroup={fg}>
      <label for="firstName">First name</label>
      {/* 2️⃣ But here we use input element with type "number" - types mismatch */}
      <input id="firstName" type="number" formControlName="firstName" />
    </form>
  </>
);

This one results in throwing a custom FormControlInvalidTypeError error with a message:

Value of the "firstName" form control is expected to be of type [number] but the type was [string].

Nested form groups

You can define nested form groups, by placing a nested record in your form group schema:

const fg = createFormGroup({
  firstName: 'John',
  // Here we create a nested form group
  address: {
    city: '',
    postalNumber: null
  }
});

This makes composing complex form models easier to maintain and logically group together.

When building complex forms, managing the different areas of information is easier in smaller sections. Using nested form groups lets you break large forms groups into smaller, more manageable ones, e.g. for styling or domain purposes.

To represent nested form groups in your template, you must wrap the form input elements for that nested form group in another element, e.g. div and declare a formGroupName attribute:

// Component definition

const fg = createFormGroup({
  firstName: 'John',
  address: {
    city: '',
    postalNumber: null
  }
});

return (
  <>
    <form use:formGroup={fg}>
      <label for="firstName">First name</label>
      <input id="firstName" type="text" formControlName="firstName" />
      
      <div formGroupName="address">
        <label for="city">City</label>
        <input id="city" type="text" formControlName="city" />
        
        <label for="postalNumber">Postal number</label>
        <input id="postalNumber" type="number" formControlName="postalNumber" />
      </div>
    </form>
  </>
);

All rules and features apply to the nested form groups as well:

  • accessing the value, disabled, dirty, touched, valid and errors signals
  • pre-configuring nested form control with disabled and validators
  • using proper HTML input elements with proper form control types
  • runtime type checking for proper using of form group keys and values

FAQ

I'm getting Uncaught ReferenceError: formGroup is not defined

If you encounter this problem, make sure you have following vite-plugin-solid options turned on:

// vite.config.ts

import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';

export default defineConfig({
  // Enable following `vite-plugin-solid` config option:
  plugins: [solidPlugin({ typescript: { onlyRemoveTypeImports: true } })],
});

Solution for the problem was found in this answer for similar issue for SolidJS directives.

I'm getting type errors when defining use:formGroup, formGroupName and formControlName attributes

If you encounter TypeScript type errors when using the formGroup directive and new attributes with your HTML elements, try extending SolidJS's JSX namespace:

declare module 'solid-js' {
  namespace JSX {
    interface Directives {
      formGroup?: {};
    }

    interface InputHTMLAttributes<T> {
      formControlName?: string;
    }

    interface SelectHTMLAttributes<T> {
      formControlName?: string;
    }

    interface HTMLAttributes<T> {
      formGroupName?: string;
    }
  }
}

This will allow you to bind Solar's form group to your form elements without TypeScript type errors related to new HTML attributes.

Roadmap

Support

If you want to say thank you and/or support development of Solar Forms:

  1. Add a GitHub Star to the project.
  2. Tweet about the project on your Twitter.
  3. Write about it on Medium, Dev.to or personal blog.

Contribution guidelines

🚧 Under construction! 🚧

Inspirations

This library is heavily inspired by Angular's reactive forms although it was adapted to match more "hook-like" or "signal-like" form of accessing form group state.

Many thanks to all people who contributed to growth of Angular's reactive forms over the years! 🙏