react-validation-layer
v1.0.0-beta.8
Published
An opinionated form validation tool for React apps
Downloads
73
Maintainers
Readme
react-validation-layer
An opinionated form validation tool for React apps.
Why
Great UX out of the box Offers predefined set of
strategies
to provide superior user experience, incl. debounced async validations.Framework agnostic It works with all state management tools (or without any). Only
React
is required.Thin Layer doesn't own the form data. Don't need to change anything in the way you manage the state to start using it. Its main and only concern is form validation with superior UX.
Declarative All you have to do is to declare behavior via props and shape the look in the markup. Layer will take care of the rest.
Table of Contents
Examples
Website is WIP and not ready for mobiles yet, sorry.
- Simple [ live · source ] Sync validations only.
- Advanced [ live · source ] Sync & async validations, linked fields etc.
Installation
Get it:
# yarn
yarn add react-validation-layer
# npm
npm install --save react-validation-layer
Usage
As simple as:
import ValidationLayer from 'react-validation-layer';
<ValidationLayer
strategy="onFirstSubmit"
data={{ email, password }}
fields={{
email: emailFieldConfig,
password: passwordFieldConfig,
}}
handlers={{
onChange: onChangeHandler,
onSubmit: onSubmitHandler,
}}
>
{layer => (
<form onSubmit={layer.handleSubmit}>
<input type="text" {...layer.getPropsFor('email')} />
<span className={layer.getStatusFor('email')}>
{layer.getMessageFor('email')}
</span>
<input type="text" {...layer.getPropsFor('password')} />
<span className={layer.getStatusFor('password')}>
{layer.getMessageFor('password')}
</span>
<button {...layer.getSubmitButtonProps()}>
Submit
</button>
</form>
)}
</ValidationLayer>
Configuration
Layer requires 3 things to handle validation of the form:
- data to validate and render
- fields list with configurations
- and onChange + onSubmit handlers.
Some options can be set either on the global level (i.e. for all fields), or on the field
level. field
-level option has priority and overrides the global one.
props.id
Required: no
Default: 'form'
type LayerId = string;
The ID of the layer to avoid DOM ids collisions. This value is a namespace for generated DOM ids to ensure their uniqueness. Use it if you have few forms on a single page.
props.data
Required: yes
Default: —
type Data = { [attr: string]: any };
Object with the form data. Layer doesn't care about how you manage the state. You can use vanilla React, or Redux, or whatever as a state container. Just pack the data for the form into single object and pass it to validation layer. Object can be flat or nested (see next section). immutable
structures are supported (no need to call toJS()
).
props.fields
Required: yes
Default: —
type Field = boolean | {
strategy?: Strategy,
asyncStrategy?: AsyncStrategy,
validate?: (value: Value, data: Data) => ValidationResults,
validateAsync?: (value: Value, data: Data) => Promise<ValidationResults>,
debounceInterval?: number,
linkedFields?: Array<string | KeyPath>,
filter?: (value: DomValue, data: Data) => boolean,
transformBeforeStore?: (value: DomValue, data: Data) => Value,
transformBeforeRender?: (value: Value, data: Data) => DomValue,
handlers?: Handlers,
};
type Fields = { [attribute: string]: Field | Fields };
Object with form fields config. If a field doesn't have validations and other special handlers, then it must be set to true
to let the layer know that this field exists, thus it can serve props for it.
Flat structures
In case if data
is flat, fields
object must also be flat:
const data = {
username: 'alex',
email: '[email protected]',
};
const fields = {
username: true, // nothing special about this field, but letting layer know about it
email: { validate: email => !!email }, // config for `email` field
};
Nested structures
Sometimes data
is nested, fields
object must replicate the shape of the data
object:
const data = {
username: 'alex',
email: '[email protected]',
creditCard: {
number: '1234567890',
owner: 'ALEX FEDOSEEV',
},
};
const fields = {
username: true, // nothing special about this field, but letting layer know about it
email: { validate: email => !!email }, // config for `email` field
creditCard: { // replicating the shape
number: { validate: number => !!number },
owner: { validate: owner => !!owner },
},
};
field.strategy
Can be set on global or on the field level. See props.strategy
for details.
field.asyncStrategy
Can be set on global or on the field level. See props.asyncStrategy
for details.
field.validate
Required: no
Default: —
type Validate = (value: Value, data: Data) => ValidationResults;
type ValidationResults = boolean | {
valid: boolean,
message?: string,
status?: string,
};
Validation function, which takes value
and data
object (the one that is passed to <ValidationLayer />
). It can return either boolean
or Object
.
ValidationResults
as an object contains:
valid
: boolean flag, tells if passed value is valid or not. Required.message
: usually a text string (or i18n id of a text string) which will show up in the view, when this result will be emitted. Actually it can be whatever you want, e.g. array of strings. Not required, if you don't use it.status
: suppose to identify css class, e.g.success
orfailure
(those are defaults). Keep in mind that you can pass here any string to provide rich feedback to the user. E.g. when you validate credit card field, on successful validation instead of simplesuccess
status, you can passvisa
/mastercard
etc to display icon of the payment system. Not required, if you don't use it or fine with defaults. Also seeprops.statuses
.
NOTE: In case if you want to re-use validators somewhere else, react-validation-layer
exposes normalizeValidationResults
util, which takes result from the field.validate
and normalizes it to ValidationResults
object shape.
import { normalizeValidationResults } from 'react-validation-layer';
const normalizedValidationResults = normalizeValidationResults(field.validate(email));
// => always object, e.g. `{ valid: true }`
field.validateAsync
Required: no
Default: —
type ValidateAsync = (value: Value, data: Data) => Promise<ValidationResults>;
Async validation function, which takes value
and data
object as well, but returns a Promise
, which must be resolved with the same ValidationResults
(see field.validate
).
field.debounceInterval
Can be set on global or on the field level. See props.debounceInterval
for details.
field.linkedFields
Required: no
Default: —
type LinkedFields = Array<string | KeyPath>;
Some fields might be dependent on each other and when the value of the one field is changed, another field might become valid or invalid, e.g. password
& passwordConfirmation
. Here you can define such relations.
How it works: when you define linkedFields
for the field, you instruct the layer: "When the value of this field is changed, also re-validate following fields". Validators of the linked fields will receive data
object with updated value of the parent field.
field.filter
Required: no
Default: —
type Filter = (value: DomValue, data: Data) => boolean;
Sometimes you want to filter out some user input. Filter function takes value
and data
object. If it returns false
, then handler.onChange
won't be triggered. It means that user's input will be ignored.
NOTE: filter
doesn't filter empty strings.
field.transformBeforeStore
Required: no
Default: —
type TransformBeforeStore = (value: DomValue, data: Data) => Value;
event.target.value
is always a string
. However, some attributes should be number
by its nature or differentiate from the DOM representation in another way. To keep your data clean, you can provide transformation function, which will take string value from the DOM and transform it before send it to the data store. Here are few examples:
price
field must be anumber
thus it can be safely used for calculations- in the view we want
creditCard
to be shown w/ spaces (eg1234 5678 8765 4321
), but inside the data store it should be spaceless:1234567887654321
.
field.transformBeforeRender
Required: no
Default: —
type TransformBeforeRender = (value: Value, data: Data) => DomValue;
In a certain way, this is opposite to previous method. If you want to format your number or represent credit card w/ spaces in the form field, you can make it happen using this hook.
field.handlers
Required: no
Default: —
Can be set on global or on the field level. This object is equal to props.handlers
, except you can't define onSubmit
handler here.
props.strategy
Required: no
Default: 'onFirstSuccessOrFirstBlur'
type Strategy =
| 'onFirstBlur'
| 'onFirstChange'
| 'onFirstSuccess'
| 'onFirstSuccessOrFirstBlur'
| 'onFirstSubmit'
;
In most cases validation feedback should be provided as soon as possible, but not too soon. The question comes down to when to start to provide the feedback. It really depends on context. Strategies below won't provide any feedback until the specific moment, e.g. the first blur from the field or the first successful validation. All you have to do is to pick the most suitable one for your context. To understand the behavior of each strategy, add the following prefix to its name: "Start providing instant feedback on..."
onFirstChange
Validation Layer emits results for the single field as user types. Note that first feedback will be provided only after first change in this field.
onFirstBlur
Validation Layer emits results on first blur. After first results were emitted—feedback is provided on every change in this field.
onFirstSuccess
Validation Layer emits results on first successful validation. After first results were emitted—feedback is provided on every change in this field.
onFirstSuccessOrFirstBlur
✨
Validation Layer emits first results immediately on successful validation or on the first blur. After first results were emitted—feedback is provided on every change in this field.
onFirstSubmit
Validation Layer emits first results only after first submission attempt. After this results for each field are emitted on every change in this field until validation layer will be reseted or remounted.
NOTE: After first submission of the form all fields are switched to onFirstSubmit
strategy. It means that each field will receive feedback on every change in the field.
props.asyncStrategy
Required: no
Default: 'onChange'
type AsyncStrategy =
| 'onBlur'
| 'onChange'
;
Some validations can't be performed locally, e.g. on signup you want to validate if email from the input is available or already taken.
onChange
✨
There are 2 common ways to provide async feedback: request the server on every change or only on blur event. The first one is better in terms of UX, but creates significant load, so your client might become slow or server might feel bad. The blur option doesn't have this problem (at least not that much), but UX is definitely not the best, b/c user have to blur away from the field to get the feedback.
What can we do about it to have the best of both worlds? The answer is to debounce on change async validations. What does it mean and how does it work: when user types something in in the form field, no external requests are triggered. Instead, it's put on hold. While user types, we wait. Once he stopped and there was no activity in the certain period—request is triggered.
Validation layer does this out of the box. Just enable onChange
async strategy and you're all set 🤘
Also, it's a good UX to provide feedback in UI, when async validation is started. E.g. show a little spinner where you show your messages. Layer will let you know when to render it via layer.getAsyncStatusFor
.
onBlur
This strategy triggers async validation only on blur event. Use this if even debounced validations hurt your server (but don't forget that you can setup debounceInterval
, it might help to reduce the load).
Few more things to keep in mind about async validations:
- If sync strategy doesn't emit results -> layer doesn't trigger async validation.
- If sync validation fails -> layer doesn't trigger async validation.
- If sync validation succeeded and there is async validator for the field -> results will be emitted only from async validation: when async validation is triggered, layer will notify about the start of async validation, and when results will be resolved -> layer will serve them via props (as usual).
- Layer does not perform any async validations on form submission as those validations will be performed on the server anyway within a single request (form submission). If server will reject submission and report errors, you can notify layer about it via callback, which accepts errors as argument (see
props.handlers
).
N.B. Single strategy can be set for all the fields globally (root props strategy
& asyncStrategy
of <ValidationLayer />
), as well as on per-field basis (field.strategy
& field.asyncStrategy
). Field-level strategy has higher priority, so, if it's set, it will override global strategy for current field.
props.debounceInterval
Required: no
Default: 700
type DebounceInterval = number;
Configure amount of time (in ms
) that layer should wait after last user activity before debounced async validation will be invoked.
props.statuses
Required: no
Default: { success: 'success', failure: 'failure' }
type Statuses = {
success: string,
failure: string,
};
These are default statuses for successful and failed validation results. Used, if no special values are provided from validators. Redefine it if you don't like the default ones.
props.handlers
Required: yes
Default: —
type Handlers = {
onChange?: (updatedData: UpdatedData) => void,
onBlur?: (updatedData: UpdatedData) => void,
onSubmit: (callbacks: OnSubmitCallbacks) => void,
};
Tell the layer how to handle data updates and form submission. onChange
and onBlur
can be defined on the field level, so if every field has its own onChange
method, on the props level it's not required. onBlur
is always optional, but check out its section below for gotchas. onSubmit
can be set only here and it is required.
handlers.onChange
This is the method, which you must use to update form state in your app. It receive one argument from validation layer:
type OnChange = (updatedData: UpdatedData) => void;
type UpdatedData = {
// Attribute that was updated
attr: string,
// If attribute is nested, this is key path to it
keyPath: Array<string>,
// Next value of attribute
value: Value,
// Value of `checked` DOM attribute
checked: boolean,
// Original data object (note: doesn't contain updated value)
data: Data,
// Original DOM event
event: SyntheticInputEvent,
|};
If transformBeforeStore
method is defined for this field, then the updatedData.value
will be the returned value from this method, otherwise it's just a string
from the DOM.
NB onChange
handler must put in state exactly the same value
, that was passed to it from the layer, as layer uses it for validation.
handlers.onBlur
Usually you don't need this. So if it's the case, just ignore this handler. But if you actually want to do some stuff on blur event—don't override layer's handler in representation by putting onBlur
prop on DOM input field directly, but provide it to the layer here and it will trigger it for you (with the same UpdatedData
object as argument). Otherwise layer won't be able to handle blur events correctly. If you still want to redefine it from the representation, then see layer.notifyOnBlur
.
handlers.onSubmit
This method will be triggered on form submission if all fields of the form are passed validation. It receives object with 2 callbacks as argument:
type OnSubmit = (callbacks: OnSubmitCallbacks) => void;
type OnSubmitCallbacks = {
onSuccess: () => void,
onFailure: (errors: {}) => void,
};
Invoke onSuccess
after successful response from the server. It will reset internal validation layer state to its initial state.
In case if something went wrong and your API responded with errors, invoke onFailure
callback with these errors. Layer will pass them to representation in general way. Error object must replicate the shape of the data
/ fields
objects, e.g.:
const externalErrors = {
email: 'Bad email',
creditCard: {
number: 'Bad credit card number',
},
};
Rendering
Validation layer requires children
to be a function. This function receives single argument layer
: an interface to the data from validation layer. Here is how it looks like:
<ValidationLayer {...}>
{layer => (
<form onSubmit={layer.handleSubmit}>
<input type="text" {...layer.getPropsFor('email')} />
<input type="text" {...layer.getPropsFor('password')} />
<button {...layer.getSubmitButtonProps()}>
Submit
</button>
</form>
)}
</ValidationLayer>
And here is what you can get:
getPropsFor
getCheckboxPropsFor
getRadioButtonPropsFor
getCustomPropsFor
getSubmitButtonProps
getValidityFor
isSuccessFor
isFailureFor
getStatusFor
getMessageFor
getAsyncStatusFor
getSubmissionStatus
getDomIdFor
getFieldIdFor
notifyOnChange
notifyOnBlur
handleSubmit
resetState
Providing paths to field data
Usually you getSomething
for specific field. In case if your fields
object is flat, just pass attribute name to getter:
const fields = { email: true };
layer.getStatusFor('email') // <- string
If you deal with nested structures and want to getSomething
for the field, that's nested more than 1 level deep, provide key path to it:
const fields = { user: { email: true } };
layer.getStatusFor(['user', 'email']) // <- array of strings
layer.getPropsFor
type GetPropsFor = (attr: string | KeyPath) => FieldDomProps;
<input type="text" {...layer.getPropsFor('email')} />
Returns props for general input DOM element (e.g. text input). It contains props like value
, onChange
etc. Apply it via spread operator.
layer.getCheckboxPropsFor
type GetCheckboxPropsFor = (attr: string | KeyPath) => FieldDomPropsWithChecked;
<input type="checkbox" {...layer.getCheckboxPropsFor('subscribe')} />
Returns props for checkbox. Same as getPropsFor
, but with checked
attribute.
layer.getRadioButtonPropsFor
type GetRadioButtonPropsFor = (attr: string | KeyPath, value: string) => FieldDomPropsWithChecked;
<input type="radio" {...layer.getRadioButtonPropsFor('paymentMethod', 'card')} />
<input type="radio" {...layer.getRadioButtonPropsFor('paymentMethod', 'paypal')} />
<input type="radio" {...layer.getRadioButtonPropsFor('paymentMethod', 'cash')} />
When you render radio buttons, you must render one radio button for each possible value of the attribute. So, in addition to attribute, pass value
as a second argument to get props for radio button.
layer.getCustomPropsFor
type GetCustomPropsFor = (attr: string | KeyPath, options: Options) => FieldDomProps | FieldDomPropsWithChecked;
type Options = {
value?: string,
disabled?: ?boolean,
getChecked?: (value: string) => boolean,
};
Sometimes you want to do fancy stuff in UI and involve uncommon logic. If you can't achieve what you want with standard getters, here is constructor for you. It takes attribute name/key path + object with options (all keys are optional):
value
: if provided, used to build radio button DOM id (NOT asvalue
DOM attribute!). Should be astring
. Use it only if you render radio button.disabled
: if provided, will be used fordisabled
DOM attribute. Should beboolean
.getChecked
: if provided, will be used to getchecked
DOM attribute. It must be a function, which takes 1 argument: DOMvalue
of the field, and returnsboolean
.
layer.getSubmitButtonProps
type GetSubmitButtonProps = () => SubmitButtonDomProps;
<button {...layer.getSubmitButtonProps()} />
Returns props for submit button. Its only purpose is to disable button on form submission to prevent multiple submissions.
layer.getValidityFor
type GetValidityFor = (attr: string | KeyPath) => boolean | null;
Returns validity for the field. Keep in mind that in case if layer, according to strategy, isn't ready to provide feedback yet, it will return null
.
layer.getStatusFor
type GetStatusFor = (attr: string | KeyPath) => string;
Returns status
for a field. The one that is passed (or not) via validation results.
Keep in mind that in case if layer, according to strategy, isn't ready to provide a feedback yet, it will return null
.
Also, in case if filed doesn't have a value, but still valid, status still is set to null
. This is because the main use-case for status
is CSS class name, but we don't want to paint everything green if field is empty.
layer.isSuccessFor
type IsSuccessFor = (attr: string | KeyPath) => boolean;
Basically, it's a shorthand to get boolean result if success status is emitted for a field (instead of using comparison operators with layer.getStatusFor
). Keep in mind, that emitting behavior is consistent with the layer.getStatusFor
method: it will return false
if, according to strategy, layer isn't ready to emit status
or value is not present (even if it's a valid case for a field). To get the validity, use layer.getValidityFor
layer.isFailureFor
type IsFailureFor = (attr: string | KeyPath) => boolean;
Same as layer.isSuccessFor
, but for the failure status.
layer.getMessageFor
type GetMessageFor = (attr: string | KeyPath) => string;
Returns message
for the field. The one that is passed (or not) via validation results. Keep in mind that in case if layer, according to strategy, isn't ready to provide feedback yet, it will return null
.
layer.getAsyncStatusFor
type GetAsyncStatusFor = (attr: string | KeyPath) => string;
Returns true
if async validation is in process for the field.
layer.getSubmissionStatus
type GetSubmissionStatus = () => boolean;
Returns true
if form is submitting.
layer.getDomIdFor
type GetDomIdFor = (attr: string | KeyPath, value?: string) => string;
<label htmlFor={layer.getDomIdFor('email')}>
Email
</label>
<input type="text" {...layer.getPropsFor('email')} />
Returns id
attribute of the DOM element for the field. You might need it for a various reasons, the most commonly used one is to provide DOM field id
to <label />
for the field. Don't forget to provide value
as second argument if you need id
for radio button.
layer.getFieldIdFor
type GetFieldIdFor = (attr: string | KeyPath) => string;
Returns internal id
of the field. You might need it for custom notifiers (see below).
layer.notifyOnChange
type NotifyOnChange = (fieldId: FieldId, value: Value) => void;
layer.notifyOnChange(
layer.getFieldIdFor('startDate'),
nextStartDate,
);
If you do some fancy stuff with the field (e.g. update its value via JS / third-party tool, e.g. date picker), then use this method to notify layer about the change, so it can perform validations.
layer.notifyOnBlur
type NotifyOnBlur = (
fieldId: FieldId,
value: Value,
event: SyntheticInputEvent,
) => void;
layer.notifyOnBlur(
layer.getFieldIdFor('startDate'),
startDate,
event,
);
Same as layer.notifyOnChange
, but to notify layer about blur
events. You might need it in case if you've redefined onBlur
handler of the DOM node, but still want to notify layer about event, so it can use this information to figure out correct strategy. Don't forget to pass event
as last arg.
layer.handleSubmit
<form onSubmit={layer.handleSubmit} />
^ That's all you need to do with it.
layer.resetState
<button onClick={layer.resetState}>
Reset
</button>
Resets validation layer internal state.
Lifecycles
Here is the quick overview what's happening on data updates and form submission under the hood.
Value update
onChange
/onBlur
handler is triggered from the DOMfield.filter
is triggered?->
- if
false
is returned->
we're done - if
true
is returned->
continue
- if
field.transformBeforeStore
is triggered (if it's defined)field.onChange
(orprops.onChange
) is triggered- sync validation is triggered (if any)
?->
- if no results emitted
->
updating the state and we're done - if failed results emitted
->
updating the state and we're done - if success results emitted
?->
- if there is no async validation
->
updating the state and we're done - if there is async validation
->
updating the state with processing status and trigger async validation, when results are resolved->
updating the state and we're done.
- if there is no async validation
- if no results emitted
Form submission
layer.handleSubmit
is triggered from the DOM- Validation layer performs sync validation of all fields (no async validations performed)
?->
- if at least one field is invalid
->
updating the state with the errors and we're done - if all fields are valid
->
triggeringhandlers.onSubmit
and pass object with callbacks?->
callback.onSuccess
should be triggered when form is successfully submitted->
resetting the layer to initial state and we're donecallback.onFailure
should be triggered when something gone wrong, i.e. API returned errors, which should be passed tocallback.onFailure
->
updating the state with the errors and we're done.
- if at least one field is invalid
WIPs & TODOs
Those will be figured out (sooner or later), upvote them if you need them :+1:
- [ ] Collections handling, e.g. arrays of entities (#12)
- [ ] Refs handling, e.g. focus on first invalid input after submission (#13)
See issues & pull requests for more details.
License
It's MIT.