per-form React
website·Indexed 29 days ago
https://tonai.github.io/react-per-formA fast and easy form validation library for React based on native form validation. It provides a set of hooks and components including useForm, useWatch, useInput, and <Form> to manage form state and validation with a focus on performance by utilizing uncontrolled components.
What's inside per-form
- @per-form/react is a fast, lightweight form validation library for React that leverages native HTML capabilities. It supports both controlled components and external UI libraries, and is compatible with Next.js server actions. Key features include the ability to work without manual state management, support for async and cross-input validation, dynamic forms, and customizable error messages/translations.
Initialize a form using the <Form> component
The<Form>component provides an alternative way to initialize a@per-form/reactform. It internally handles the setup by usinguseFormand<FormProvider>, allowing you to wrap your form fields without manually managing the form context provider.Implement local field validation with useInput
When splitting a complex form into multiple components, you can declare validators at the field level using theuseInputhook. This allows the validation logic to reside within the component that renders the input. By default, local errors are not present in the form-levelerrorsobject for performance reasons; to enable this, setfilterLocalErrors: falsein theuseFormor<Form>configuration.import { Form, useInput, type IFormValues } from '@per-form/react'; const validator = (values: IFormValues) => String(values.text).includes('foo') ? '' : 'Value does not include "foo"'; function Input() { // Pass the field name and validator to useInput const { errors } = useInput({ name: 'text', validator }); return ( <> <input name="text" required /> {errors.all.text && <div className="error">{errors.all.text}</div>} </> ); } export default function Demo() { return ( <Form onSubmit={(e, values) => console.log(values)}> <Input /> <button type="submit">Submit</button> </Form> ); }Implement a submit button using useFormStates
This example demonstrates how to useuseFormStateswithin a child component to disable a submit button during the submission process and display the current form state as JSON.import { FormProvider, useForm, useFormStates } from '@per-form/react'; function Submit() { const states = useFormStates(); return ( <> <div className="actions"> <button disabled={states.isSubmitting} type="submit"> Submit </button> <button type="reset">Reset</button> </div> <pre>{JSON.stringify(states, null, 2)}</pre> </> ); } export default function Demo() { const { formProps, ...context } = useForm({ defaultValues: { a: 'foo' }, onSubmit: async (e, values) => { console.log(values); }, }); return ( <FormProvider {...context}> <form {...formProps}> <input name="a" required /> <Submit /> </form> </FormProvider> ); }Implement custom field validation
To add validation to a specific field, use the
validatorsparameter inuseFormor the<Form>component. Thevalidatorsobject should have keys matching the field names. Each validator function receives the current form values and should return an error string if validation fails, or an empty string if it passes. To optimize performance, declare the validators object outside the component to avoid creating a new reference on every render.When using this simple syntax, the error is accessible via
errors.all[fieldName].import { useForm } from '@per-form/react'; const validators = { text: (values) => String(values.text).includes('foo') ? '' : 'Value does not include "foo"', }; export default function Demo() { const { errors, formProps } = useForm({ onSubmit: (e, values) => console.log(values), validators, }); return ( <form {...formProps}> <input name="text" required /> {errors.all.text && <div className="error">{errors.all.text}</div>} <button type="submit">Submit</button> </form> ); }Define local custom messages for specific inputs
For granular control, you can provide local custom messages using theuseInputoruseInputshooks. Local messages defined withinuseInputwill override or supplement the global messages provided to the form.const localMessages = { fooError: 'Value does not include "foo"' }; function Input() { const { errors } = useInput({ name: 'text', messages: localMessages, validator: (values) => String(values.text).includes('foo') ? '' : 'fooError', }); return ( <> <input name="text" required /> {errors.all.text && <div>{errors.all.text}</div>} </> ); }Implement a simple controlled component in @per-form/react
@per-form/react primarily uses uncontrolled components for performance, but controlled components are useful for real-time reactions (e.g., filtering, searching) or when using UI libraries designed for controlled state. Since @per-form/react does not manage your local state, you must handle state updates and form resets manually. Use theonResetparameter inuseFormor the<Form>component to synchronize your local state when the form is reset.import { type ChangeEvent, type FormEvent, useState } from 'react'; import { type IFormValues, useForm } from '@per-form/react'; export default function Demo() { const [value, setValue] = useState(''); function handleChange(event: ChangeEvent<HTMLInputElement>) { setValue(event.target.value); } function handleReset() { setValue(''); } const { errors, formProps } = useForm({ onReset: handleReset, onSubmit: (e, values) => console.log(values), }); return ( <form {...formProps}> <input name="text" onChange={handleChange} value={value} /> {errors.all.text && <div>{errors.all.text}</div>} <button type="reset">Reset</button> </form> ); }Implement asynchronous validation
Validators in@per-form/reactcan be asynchronous. If a validator function returns a Promise that resolves to an error string (or an empty string for success), the library will handle the async state accordingly.const validators = { text: (values) => // Assume delay returns a Promise delay(String(values.text).includes('foo') ? '' : 'Value does not include "foo"'), };Subscribe to field changes with the watch function
The
watchfunction can be retrieved from theuseFormhook oruseFormContext. It allows you to execute a callback whenever specified field values change.It accepts two parameters:
- Callback: A function fired when values change.
- Fields: A string, array of strings, or nothing (to watch all values).
The
watchfunction returns an unsubscribe function, which should be returned within auseEffectcleanup to prevent memory leaks.import { useEffect, useState } from 'react'; import { useForm } from '@per-form/react'; export default function Demo() { const { formProps, watch } = useForm({ onSubmit: (e, values) => console.log(values), }); const [text, setText] = useState(''); useEffect(() => { // Subscribe to 'text' field and update local state // The return value of watch is the unsubscribe function return watch<{ text: string }>(({ text }) => setText(text), 'text'); }, [watch]); return ( <form {...formProps}> <input name="text" required /> <div>value = {text}</div> </form> ); }Style form fields based on validation state
There are three primary ways to style validation states in
@per-form/react:- CSS Pseudo-classes: Use
:validand:invalid. These apply immediately based on the internal validation state, regardless of whetheruseNativeValidationis enabled. - CSS Classes: Use the
errorsobject to apply a class (e.g.,.has-error). This typically applies only after a form submission attempt. - CSS :has selector: Use a selector like
input:has(+ .error)to style an input if it is immediately followed by an error element.
/* Pseudo-class approach */ input:invalid { border: 1px solid red; } /* Class approach */ .has-error { background-color: rgba(255, 0, 0, 0.1); border: 1px solid red; } /* Combined approach: only show green if it becomes valid AFTER having an error */ .has-error:valid { background-color: rgba(0, 255, 0, 0.1); border: 1px solid green; }- CSS Pseudo-classes: Use
Implement local cross-field validation with useInputs
To validate multiple fields together within a local component, use theuseInputshook. You provide an array of field names and a validator function. The system automatically generates an ID for the validator based on the comma-separated names (e.g.,'a,b'), but you can override this by providing a customidor passing an object to thevalidatorsproperty.import { Form, useInputs, type IFormValues } from '@per-form/react'; const validator = (values: IFormValues) => Number(values.a) + Number(values.b) === 42 ? '' : 'The sum must be equal to 42'; function Inputs() { const names = ['a', 'b']; // Use useInputs for cross-field validation const { errors } = useInputs({ names, validators: validator }); return ( <> <input name="a" required type="number" /> <input name="b" required type="number" /> {/* Access error via the generated comma-separated name key */} {errors.validator['a,b']?.error && ( <div className="error">{errors.validator['a,b'].error}</div> )} </> ); } export default function Demo() { return ( <Form onSubmit={(e, values) => console.log(values)}> <Inputs /> <button type="submit">Submit</button> </Form> ); }Implement dynamic field validation
For forms with a dynamic number of fields, you can update thenamesarray passed to a cross-field validator. By passing a state-driven array to thenamesproperty of the validator configuration, the validation will automatically apply to the current set of dynamic fields.import { useState } from 'react'; import { useForm } from '@per-form/react'; function validator(values) { return Object.values(values).reduce((a, b) => Number(a) + Number(b), 0) === 42 ? '' : 'The sum must be equal to 42'; } export default function Demo() { const [names, setNames] = useState<string[]>([]); const { errors, formProps } = useForm({ onSubmit: (e, values) => console.log(values), validators: { sum: { names, validator } }, }); return ( <form {...formProps}> {names.map((name) => ( <input key={name} name={name} type="number" /> ))} {errors.validator.sum?.error && <div>{errors.validator.sum.error}</div>} </form> ); }