Quick start

npm install @sparkstone/solid-validation
pnpm add @sparkstone/solid-validation

Solid 1.9 or newer is the only peer dependency.

A complete form

Everything below runs as written. Copy the whole file.

import { useForm, type Validator } from '@sparkstone/solid-validation';

const minLength =
  (min: number): Validator<HTMLInputElement> =>
  el =>
    el.value.length < min && `Use at least ${min} characters`;

export default function SignupForm() {
  const { formSubmit, validate, errors, isSubmitting, isSubmitted } = useForm();

  async function onSubmit(form: HTMLFormElement) {
    await fetch('/api/signup', { method: 'POST', body: new FormData(form) });
  }

  return (
    <form ref={formSubmit(onSubmit)}>
      <label for='email'>Email</label>
      <input id='email' type='email' name='email' required ref={validate()} />
      <span>{errors.email}</span>

      <label for='handle'>Handle</label>
      <input id='handle' name='handle' required ref={validate(() => [minLength(3)])} />
      <span>{errors.handle}</span>

      <button type='submit' disabled={isSubmitting()}>
        {isSubmitting() ? 'Saving' : 'Save'}
      </button>
      {isSubmitted() && <p>Saved</p>}
    </form>
  );
}

That is the whole API for a normal form. Here it is running:

Submit empty and both fields should refuse. Fill them in and it saves after 400ms. Anything at @example.com comes back rejected by the server.

What each piece does

useForm() returns everything the form needs. Destructuring it is required, not stylistic: ref={validate()} only compiles when validate exists as a variable in scope.

ref={validate()} puts a field in the form. With no value it checks the browser's own rules, which is where required and type="email" are handled. With an array it runs your validators after those pass.

formSubmit(onSubmit) returns a ref that takes over the form's submit event. Your callback runs only once every field passes, and receives the form element.

errors is a store keyed by each field's name. Render errors.email wherever the message should appear.

When validation happens

EventWhat happens
BlurThe field is checked. A failure writes to errors and sets aria-invalid="true".
InputIf that field is showing an error, it clears and aria-invalid goes back to "false".
SubmitEvery field is checked in order. The first failure is focused and scrolled to, and your callback does not run.
ResetAll errors clear.

Nothing is checked while you are still filling in an untouched field, which is what keeps the form quiet.

Styling invalid fields

Failing fields get aria-invalid="true":

input[aria-invalid='true'] {
  border-color: red;
}

Prefer a class? Pass one and it is added and removed alongside the attribute:

const { validate } = useForm({ errorClass: 'is-invalid' });

Next

Server errors, async checks, and fields outside a form are all in the guides. If ref={validate()} reports a type error or seems to do nothing, see Troubleshooting.

Calling submit() yourself, without a form element:

Press Continue with nothing chosen and it refuses. Pick a plan and it runs for 400ms.

Plan