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:
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
| Event | What happens |
|---|---|
| Blur | The field is checked. A failure writes to errors and sets aria-invalid="true". |
| Input | If that field is showing an error, it clears and aria-invalid goes back to "false". |
| Submit | Every field is checked in order. The first failure is focused and scrolled to, and your callback does not run. |
| Reset | All 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.