remix-validity-state
is a small React form validation library that aims to embrace HTML input validation and play nicely with Remix primitives.
Warning
This library is in very much in an alpha stage. Feedback is welcome, however production usage is strongly discouraged.
- Remix Validity State
This library is built with the following design goals in mind:
1. Leverage built-in HTML input validation attributes verbatim
What ever happened to good old <input required maxlength="30" />
? Far too often we reach for some custom validation library just to check that a value is not empty (and potentially ship a boatload of JS to the client in order to do so). Let's use what we have readily available when we can! That way we don't have to relearn something new. If you already know some of the HTML validation attributes...then you're ready to use this library.
2. Share validations between client and server
Thanks to Remix
, this is finally much more straightforward than it has been in the past. But wait 🤔, aren't we using DOM validations? We don't have a DOM on the server?!? Don't worry - in true Remix spirit, we emulate the DOM validations on the server.
3. Expose validation results via a ValidityState
-like API
We will need an API to explain the validation state of an input...good news - the web already has one! Let's #useThePlatform
and build on top of ValidityState
.
4. Permit custom sync/async validations beyond those built into HTML
Congrats for making it to bullet 4 and not leaving as soon as we mentioned the super-simple HTML validations. Don't worry - it's not lost on me that folks need to check their email addresses for uniqueness in the DB. We've got you covered with custom sync/async validations.
5. Provide limited abstractions to simplify form markup generation
Semantically correct and accessible <form>
markup is verbose. Any convenient form library oughta provide some wrapper components to make simple forms easy. However, any form library worth it's weight has to offer low level access to allow for true custom forms, and the ability to built custom abstractions for your application use-case. Therefore, any wrapper components will be little more than syntactic sugar on top of the lower level APIs.
> npm install remix-validity-state
# or
> yarn add remix-validity-state
There's a sample Remix app deployed to rvs.fly.dev that you can check out. This app source code is stored in this repository in the demo-app/
folder, so you can also run it locally via:
git clone git@github.com:brophdawg11/remix-validity-state.git
cd remix-validity-state/demo-app
npm ci
npm run dev
In order to share validations between server and client, we define a single object containing all of our form field validations, keyed by the input names. Validations are specified using the built-in HTML validation attributes, exactly as you'd render them onto a JSX <input>
.
const formValidations = {
firstName: {
required: true,
maxLength: 50,
},
middleInitial: {
pattern: "^[a-zA-Z]{1}$",
},
lastName: {
required: true,
maxLength: 50,
},
emailAddress: {
type: "email",
required: true,
maxLength: 50,
},
};
This allows us to directly render these attributes onto our HTML inputs via something like <input name="firstName" {...formValidations.firstName} />
In order to make these validations easily accessible, we provide them via context that should wrap your underlying <form>
. We do this with a wrapper component around the actual context for better TypeScript inference.
import { FormContextProvider } from 'remix-validity-state'
<FormContextProvider value={{ formValidations }}>
{/* Your <form> goes in here */}
</FormContextProvider
<FormContextProvider value={{ formValidations }}>
<Field name="firstName" label="First Name" />
<Field name="middleInitial" label="Middle Name" />
<Field name="lastName" label="Last Name" />
<Field name="emailAddress" label="Email Address" />
</FormContextProvider>
The <Field>
component is our wrapper that handles the <label>
, <input>
, and real-time error display. The name
serves as the key and will look up our validation attributes from context and include them on the underlying <input />
.
In Remix, your submit your forms to an action
which receives the FormData
. In your action, call validateServerFormData
with the formData
and your previously defined formValidations
:
import { validateServerFormData } from "remix-validity-state";
export async function action({ request }) {
const formData = await request.formData();
const serverFormInfo = await validateServerFormData(
formData,
formValidations
);
if (!serverFormInfo.valid) {
// Uh oh - we found some errors, send them back up to the UI for display
return json({ serverFormInfo });
}
// Congrats! Your form data is valid - do what ya gotta do with it
}
When we validate on the server, we may get errors back that we didn't catch during client-side validation (or we didn't run because JS hadn't yet loaded!). In order to render those, we can provide the response from validateServerFormData
to our FormContext
and it'll be used internally. The serverFormInfo
also contains all of the submitted input values to be pre-populated into the inputs in a no-JS scenario.
import { Field, FormContextProvider } from "remix-validity-state";
export default function MyRemixRouteComponent() {
let actionData = useActionData();
return (
<FormContextProvider
value={{
formValidations,
serverFormInfo: actionData?.serverFormInfo,
}}
>
<Field name="firstName" label="First Name" />
<Field name="middleInitial" label="Middle Name" />
<Field name="lastName" label="Last Name" />
<Field name="emailAddress" label="Email Address" />
</FormContextProvider>
);
}
You've now got a real-time client-side validated form wired up with your rock-solid server validations!
Internally, we use what we call an EnhancedValidityState
data structure which is the same format as ValidityState
, plus any additional custom validations. This looks like the following:
let enhancedValidityState = {
badInput: false, // currently unused
customError: false, // currently unused
rangeOverflow: false, // Did we fail 'max'?
rangeUnderflow: false, // Did we fail 'min'?
patternMismatch: false, // Did we fail 'pattern'?
stepMismatch: false, // Did we fail 'step'?
tooLong: false, // Did we fail 'maxlength'?
tooShort: false, // Did we fail 'minlength'?
typeMismatch: false, // Did we fail 'type'?
valueMissing: false, // Did we fail 'required'?
valid: true, // Is the input valid?
// Custom validations are appended directly in here as well!
uniqueEmail: false, // Did we fail the unique email check?
};
Custom validations are implemented as a sync or async function returning a boolean, and you add them directly into your formValidations
object where you define HTML validations:
const formValidations: FormValidations = {
name: {
required: true,
maxLength: 50,
},
emailAddress: {
required: true,
maxLength: 50,
async uniqueEmail(value) {
let res = await fetch(...);
let data = await res.json();
return data.isUnique === true;
},
},
}
Basic error messaging is handled out of the box by <Field>
for built-in HTML validations. If you are using custom validations, or if you want to override the built-in messaging, you can provide custom error messages through the <FormContextProvider>
. Custom error messages can either be a static string, or a function that receives the attribute value (built-in validations only), the input name, and the input value:
const errorMessages = {
valueMissing: "This field is required",
tooLong: (attrValue, name, value) =>
`The ${name} field can only be up to ${attrValue} characters, ` +
`but you have entered ${value.length}`,
uniqueEmail: (_, name, value) =>
`The email address ${value} is already taken`,
};
<FormContextProvider value={{ formValidations, errorMessages }}>
...
</FormContextProvider>;
This is the bread and butter of the library - and <Field>
is really nothing more than a wrapper around this hook. Let's take a look at what it gives you. The only required input is the input name
:
let { info, getInputAttrs, getLabelAttrs, getErrorsAttrs } = useValidatedInput({
name: "firstName",
});
The returned info
value is of the following structure:
interface InputInfo {
// Has this input been blur'd?
touched: boolean;
// Has this input value changed?
dirty: boolean;
// Validation state, 'idle' to start and 'validating' during any
// custom async validations
state: "idle" | "validating" | "done";
// The current validity state of our input
validity?: EnhancedValidityState;
// Map of EnhancedValidityState validation name -> error message for all current errors
errorMessages?: Record<string, string>;
}
validity
contains the current validation state of the input. Most notably validity.valid
, tells you if the input is in a valid state.
errorMessages
is present if the input is invalid, and contains the error messages that should be displayed to the user (keyed by the validation name in validity
):
{
tooLong: 'The email field can only be up to 50 characters, but you have entered 60',
uniqueEmail: 'The email address john@doe.com is already taken',
}
getInputAttrs
, getLabelAttrs
, and getErrorsAttrs
are prop getters that allow you to render you own custom <input>
/<label>
elements and error displays, while handling all of the validation attrs, id
, for
, aria-*
, and other relevant attribute for your form markup.
Let's look at an example usage:
<div>
<label {...getLabelAttrs()}>Email Address*</label>
<input {...getInputAttrs()} />
{info.touched && info.errorMessages ? (
<ul {...getErrorsAttrs()}>
{Object.values(info.errorMessages).map((msg) => (
<li key={msg}>🆘 {msg}</li>
))}
</ul>
) : null}
</div>
useValidatedInput
can also be used instead of FormContextProvider
for formValidations
and serverFormInfo
if necessary:
let { info } = useValidatedInput({
name: "emailAddress",
formValidations,
serverFormInfo,
});
Or, you can pass field-specific error message overrides that will be merged into the errorMessages
provided by the FormContext
:
let { info } = useValidatedInput({
name: "emailAddress",
errorMessages: {
required: "Please provide an email address",
},
});
This library aims to be pretty hands-off when it comes to styling, since every use-case is so different. We expect most consumers will choose to create their own custom markup with direct usage of useValidatedInput
. However, for simple use-cases of <Field />
we expose a handful of stateful classes on the elements you may hook into with your own custom styles:
rvs-label
- added to the built-in<label>
elementrvs-label--touched
- present when the input has been blur'drvs-label--dirty
- present when the input has been changedrvs-label--invalid
- present when the input is invalidrvs-label--validating
- present when the input is processing async validations
rvs-input
- added to the built-in<input>
elementrvs-input--touched
- present when the input has been blur'drvs-input--dirty
- present when the input has been changedrvs-input--invalid
- present when the input is invalidrvs-input--validating
- present when the input is processing async validations
rvs-validating
- present on the<p>
tag that displays aValidating...
message during async validationrvs-errors
- added to the built-in errors list<ul>
element
Now, I'm no TypeScript wizard but I have tried to make this library TypeScript friendly, and even got some good feature requests early on (thanks Kent for #7 and #9!). Hopefully over time the types will improve further, but at the moment here's the best way to get type safety and inference.
// Define a type for your validations
type MyValidations = {
firstName: Validations;
lastName: Validations;
}
// Create your typed validations
const formValidations: MyValidations = {
firstName: { required: true },
lastName: { required: true },
}
// When passing formValidations to context./hooks it will automatically infer
// your types:
<FormContextProvider value={{ formValidations }}>
useValidatedInput({ name: "firstName", formValidations });
// Or if you are using useValidatedInput inside the context, you'll need to
// use the generic signature:
useValidatedInput<MyValidations>({ name: 'firstName' });
// Finally, the return type of validateServerFormData will have serverFormInfo.inputs
// properly typed with your fields
Currently, this library only supports simple <input>
elements. The following items are not currently supported, but are planned for any formal v1.0 release:
- Error message interpolation
- Radio Buttons
- Checkboxes
- Select
- Textarea
- Form level
info.valid
object (for disabling submit etc.)
Feedback is absolutely welcomed! This is a bit of a side hobby for me - as I've built plenty of forms over the years and I've never been particularly satisfied with the libraries available. So this is somewhat of an attempt to build my ideal validation library - and I would love ideas that could improve it. So please feel free to file issues, opens PRs, etc.
Here's a few guidelines if you choose to contribute!
- Find a bug? Please file an Issue with a minimal reproduction. Ideally a working example in stackblitz/codesandbox/etc., but sample code can suffice in many cases as well.
- Fix a bug? You rock 🙌 - please open a PR.
- Have a feature idea? Please open feature requests as a Discussion so we can use the forum there to come up with a solid API.