The form state management library that can handle hundreds of fields without breaking a sweat.
- Expressive and concise API with strict typings.
- Controlled and uncontrolled inputs.
- Unparalleled extensibility with plugins.
- Compatible with Standard Schema ↗.
- Just 2 kB gzipped. ↗
npm install --save-prod roqueform
🔰 Features
🔌 Built-in plugins
- Annotations plugin
- Errors plugin
- DOM element reference plugin
- Reset plugin
- Scroll to error plugin
- Uncontrolled plugin
- Validation plugin
- Schema plugin
- Constraint validation API plugin
The central piece of Roqueform is the concept of a field. A field holds a value and provides a means to update it.
Let's start by creating a field:
import { createField } from 'roqueform';
const field = createField();
// ⮕ Field<any>
A value can be set to and retrieved from the field:
field.setValue('Pluto');
field.value; // ⮕ 'Pluto'
Provide the initial value for a field:
const ageField = createField(42);
// ⮕ Field<number>
ageField.value; // ⮕ 42
The field value type is inferred from the initial value, but you can explicitly specify the field value type:
interface Planet {
name: string;
}
interface Universe {
planets: Planet[];
}
const universeField = createField<Universe>();
// ⮕ Field<Universe | undefined>
universeField.value; // ⮕ undefined
Retrieve a child field by its key:
const planetsField = universeField.at('planets');
// ⮕ Field<Planet[] | undefined>
planetsField
is a child field, and it is linked to its parent universeField
.
planetsField.key; // ⮕ 'planets'
planetsField.parent; // ⮕ universeField
Fields returned by
the Field.at
↗
method have a stable identity. This means that you can invoke at(key)
with the same key multiple times and the same
field instance is returned:
universeField.at('planets');
// ⮕ planetsField
So most of the time you don't need to store a child field in a variable if you already have a reference to a parent field.
The child field has all the same functionality as its parent, so you can access its children as well:
planetsField.at(0).at('name');
// ⮕ Field<string | undefined>
When a value is set to a child field, a parent field value is also updated. If parent field doesn't have a value yet, Roqueform would infer its type from a key of the child field.
universeField.value; // ⮕ undefined
universeField.at('planets').at(0).at('name').setValue('Mars');
universeField.value; // ⮕ { planets: [{ name: 'Mars' }] }
By default, for a key that is a numeric array index, a parent array is created, otherwise an object is created. You can change this behaviour with custom accessors.
When a value is set to a parent field, child fields are also updated:
const nameField = universeField.at('planets').at(0).at('name');
nameField.value; // ⮕ 'Mars'
universeField.setValue({ planets: [{ name: 'Venus' }] });
nameField.value; // ⮕ 'Venus'
You can subscribe to events published by a field:
const unsubscribe = planetsField.subscribe(event => {
if (event.type === 'valueChanged') {
// Handle the field value change
}
});
// ⮕ () => void
All events conform the
FieldEvent
↗
interface.
Without plugins, fields publish only
valueChanged
↗
event when the field value is changed via
Field.setValue
↗.
The root field and its descendants are updated before valueChanged
event is published, so it's safe to read field
values in a listener.
Fields use SameValueZero ↗ comparison to detect that the value has changed.
planetsField
.at(0)
.at('name')
.subscribe(event => {
// Handle the event here
});
// ✅ The value has changed, the listener is called
planetsField.at(0).at('name').setValue('Mercury');
// 🚫 The value is unchanged, the listener isn't called
planetsField.at(0).setValue({ name: 'Mercury' });
Plugins may publish their own events. Here's an example of the errorAdded
event published by
the errorsPlugin
.
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
const field = createField({ name: 'Bill' }, [errorsPlugin()]);
field.subscribe(event => {
if (event.type === 'errorAdded') {
// Handle the error here
event.payload; // ⮕ 'Illegal user'
}
});
field.addError('Illegal user');
Event types published by fields and built-in plugins:
- valueChanged
- The new value was set to the target field. The event payload contains the old value.
- initialValueChanged
- The new initial value was set to the target field. The event payload contains the old initial value.
- validityChanged
- The field's validity state has changed. The event payload contains the previous validity state.
- errorAdded
- An error was added to a field. The event payload contains an error that was added.
- errorDeleted
- An error was deleted from a field. The event payload contains an error that was deleted.
- errorsCleared
- All errors were removed from the field. The event payload contains the previous array of errors.
- errorDetected
- An event type that notifies the errors plugin that an error must be added to a target field. The event payload must contain an error to add.
- annotationsChanged
- Field annotations were patched. The event payload contains the annotations before the patch was applied.
- validationStarted
- The validation of the field has started. The event payload contains the validation that has started.
- validationFinished
- The validation of the field has finished. The event payload contains the validation that has finished.
When you
call Field.setValue
↗
on a field its value is updates along with values of its ancestors and descendants. To manually control the update
propagation to fields ancestors, you can use transient updates.
When a value of a child field is set transiently, values of its ancestors aren't immediately updated.
const field = createField();
// ⮕ Field<any>
field.at('hello').setTransientValue('world');
field.at('hello').value; // ⮕ 'world'
// 🟡 Parent value wasn't updated
field.value; // ⮕ undefined
You can check that a field is in a transient state:
field.at('hello').isTransient; // ⮕ true
To propagate the transient value contained by the child field to its parent, use the
Field.flushTransient
↗
method:
field.at('hello').flushTransient();
// 🟡 The value of the parent field was updated
field.value; // ⮕ { hello: 'world' }
Field.setTransientValue
↗
can be called multiple times, but only the most recent update is propagated to the parent field after
the Field.flushTransient
call.
When a child field is in a transient state, its value visible from the parent may differ from the actual value:
const planetsField = createField(['Mars', 'Pluto']);
planetsField.at(1).setTransientValue('Venus');
planetsField.at(1).value; // ⮕ 'Venus'
// 🟡 Transient value isn't visible from the parent
planetsField.value[1]; // ⮕ 'Pluto'
Values are synchronized after the update is flushed:
planetsField.at(1).flushTransient();
planetsField.at(1).value; // ⮕ 'Venus'
// 🟡 Parent and child values are now in sync
planetsField.value[1]; // ⮕ 'Venus'
ValueAccessor
↗
creates, reads and updates field values.
-
When the child field is accessed via
Field.at
↗ method for the first time, its value is read from the value of the parent field using theValueAccessor.get
↗ method. -
When a field value is updated via
Field.setValue
↗, then the parent field value is updated with the value returned from theValueAccessor.set
↗ method. If the updated field has child fields, their values are updated with values returned from theValueAccessor.get
↗ method.
By default, Roqueform uses
naturalValueAccessor
↗
which supports:
- plain objects,
- class instances,
- arrays,
Map
-like instances,Set
-like instances.
If the field value object has add()
and [Symbol.iterator]()
methods, it is treated as a Set
instance:
const usersField = createField(new Set(['Bill', 'Rich']));
usersField.at(0).value; // ⮕ 'Bill'
usersField.at(1).value; // ⮕ 'Rich'
If the field value object has get()
and set()
methods, it is treated as a Map
instance:
const planetsField = createField(
new Map([
['red', 'Mars'],
['green', 'Earth'],
])
);
planetsField.at('red').value; // ⮕ 'Mars'
planetsField.at('green').value; // ⮕ 'Earth'
When the field is updated, naturalValueAccessor
infers a parent field value from the child field key: for a key that
is a numeric array index, a parent array is created, otherwise an object is created.
const carsField = createField();
carsField.at(0).at('brand').setValue('Ford');
carsField.value; // ⮕ [{ brand: 'Ford' }]
You can explicitly provide a custom accessor along with the initial value:
import { createField, naturalValueAccessor } from 'roqueform';
const field = createField(['Mars', 'Venus'], undefined, naturalValueAccessor);
FieldPlugin
↗ callbacks
that are invoked once for each newly created field. Plugins can constrain the type of the root field value and add
mixins to the root field and its descendants.
Pass an array of plugins that must be applied
to createField
↗:
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
const field = createField({ hello: 'world' }, [errorsPlugin()]);
A plugin receives a mutable field instance and should enrich it with the additional functionality. To illustrate how plugins work, let's create a simple plugin that enriches a field with a DOM element reference.
import { FieldPlugin } from 'roqueform';
interface MyValue {
hello: string;
}
interface MyMixin {
element: Element | null;
}
const myPlugin: FieldPlugin<MyValue, MyMixin> = field => {
// 🟡 Initialize mixin properties
field.element = null;
};
To apply the plugin to a field, pass it to the field factory:
const field = createField({ hello: 'world' }, [myPlugin]);
// ⮕ Field<MyValue, MyMixin>
field.element; // ⮕ null
The plugin is applied to the field
itself and its descendants when they are accessed for the first time:
field.at('hello').element; // ⮕ null
Plugins can publish custom events. Let's update the myPlugin
implementation so it
publishes an event when an element is changed:
import { FieldPlugin } from 'roqueform';
interface MyMixin {
element: Element | null;
setElement(element: Element | null): void;
}
const myPlugin: FieldPlugin<MyValue, MyMixin> = field => {
field.element = null;
field.setElement = element => {
field.element = element;
// 🟡 Publish an event for field listeners
field.publish({
type: 'elementChanged',
target: field,
relatedTarget: null,
payload: element,
});
};
};
Field.publish
↗
invokes listeners subscribed to the field and its ancestors, so events bubble up to the root field which effectively
enables event delegation:
const field = createField({ hello: 'world' }, [myPlugin]);
// 1️⃣ Subscribe a listener to the root field
field.subscribe(event => {
if (event.type === 'elementChanged') {
event.target.element; // ⮕ document.body
}
});
// 2️⃣ Event is published by the child field
field.at('hello').setElement(document.body);
annotationsPlugin
↗
associates arbitrary data with fields.
import { createField } from 'roqueform';
import annotationsPlugin from 'roqueform/plugin/annotations';
const field = createField({ hello: 'world' }, [
annotationsPlugin({ isDisabled: false }),
]);
field.at('hello').annotations.isDisabled; // ⮕ false
Update annotations for a single field:
field.annotate({ isDisabled: true });
field.annotations.isDisabled; // ⮕ true
field.at('hello').annotations.isDisabled; // ⮕ false
Annotate field and all of its children recursively:
field.annotate({ isDisabled: true }, { isRecursive: true });
field.annotations.isDisabled; // ⮕ true
// 🌕 The child field was annotated along with its parent
field.at('hello').annotations.isDisabled; // ⮕ true
Annotations can be updated using a callback. This is especially useful in conjunction with recursive flag:
field.annotate(
field => {
// Toggle isDisabled for the field and its descendants
return { isDisabled: !field.annotations.isDisabled };
},
{ isRecursive: true }
);
Subscribe to annotation changes:
field.subscribe(event => {
if (event.type === 'annotationsChanged') {
event.target.annotations; // ⮕ { isDisabled: boolean }
}
});
errorsPlugin
↗ associates
errors with fields:
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
const field = createField({ hello: 'world' }, [errorsPlugin<string>()]);
field.at('hello').addError('Invalid value');
Read errors associated with the field:
field.at('hello').errors;
// ⮕ ['Invalid value']
Check that the field has associated errors:
field.at('hello').isInvalid; // ⮕ true
Get all fields that have associated errors:
field.getInvalidFields();
// ⮕ [field.at('hello')]
Delete an error from the field:
field.at('hello').deleteError('Invalid value');
Clear all errors from the field and its descendants:
field.clearErrors({ isRecursive: true });
By default, the error type is unknown
. To restrict type of errors that can be added to a field, provide it explicitly:
interface MyError {
message: string;
}
const field = createField({ hello: 'world' }, [
errorsPlugin<MyError>(),
]);
field.errors; // ⮕ MyError[]
By default, if an error is an object that has a message
field, it is added only if a message
value is distinct.
Otherwise, if an error isn't an object or doesn't have a message
field, then it is added only if it has a unique
identity. To override this behavior, provide an error concatenator callback:
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
const field = createField({ hello: 'world' }, [
errorsPlugin<MyError>((prevErrors, error) => {
return prevErrors.includes(error) ? prevErrors : [...prevErrors, error];
}),
]);
To add an error to field, you can publish an errorDetected
event instead of calling
the addError
↗
method:
field.publish({
type: 'errorDetected',
target: field,
relatedTarget: null,
payload: 'Ooops',
});
field.errors; // ⮕ ['Oops']
This is especially useful if you're developing a plugin that adds errors to fields but you don't want to couple with the errors plugin implementation.
Subscribe to error changes:
field.subscribe(event => {
if (event.type === 'errorAdded') {
event.target.errors; // ⮕ MyError[]
}
});
refPlugin
↗ associates DOM
elements with fields.
import { createField } from 'roqueform';
import refPlugin from 'roqueform/plugin/ref';
const field = createField({ hello: 'world' }, [refPlugin()]);
field.at('hello').ref(document.querySelector('input'));
Access an element associated with the field:
field.at('hello').element; // ⮕ Element | null
Focus and blur an element referenced by a field. If a field doesn't have an associated element this is a no-op.
field.at('hello').focus();
field.at('hello').isFocused; // ⮕ true
Scroll to an element:
field.at('hello').scrollIntoView({ behavior: 'smooth' });
resetPlugin
↗ enhances fields
with methods that manage the initial value.
import { createField } from 'roqueform';
import resetPlugin from 'roqueform/plugin/reset';
const field = createField({ hello: 'world' }, [resetPlugin()]);
field.at('hello').setValue('universe');
field.value; // ⮕ { hello: 'universe' }
field.reset();
// 🟡 The initial value was restored
field.value; // ⮕ { hello: 'world' }
Change the initial value of a field:
field.setInitialValue({ hello: 'universe' });
field.at('hello').initialValue; // ⮕ 'universe'
The field is considered dirty when its value differs from the initial value. Values are compared using an equality
checker function passed to
the resetPlugin
↗.
By default, values are compared using
fast-deep-equal ↗.
const field = createField({ hello: 'world' }, [resetPlugin()]);
field.at('hello').setValue('universe');
field.at('hello').isDirty; // ⮕ true
field.isDirty; // ⮕ true
Get the array of all dirty fields:
field.getDirtyFields();
// ⮕ [field, field.at('hello')]
Subscribe to initial value changes:
field.subscribe(event => {
if (event.type === 'initialValueChanged') {
event.target.initialValue;
}
});
scrollToErrorPlugin
↗
enhances the field with methods to scroll to the closest invalid field.
import { createField } from 'roqueform';
import scrollToErrorPlugin from 'roqueform/plugin/scroll-to-error';
const field = createField({ hello: 'world' }, [scrollToErrorPlugin()]);
// Associate a field with a DOM element
field.at('hello').ref(document.querySelector('input'));
// Mark a field as invalid
field.at('hello').isInvalid = true;
// 🟡 Scroll to an invalid field
field.scrollToError();
// ⮕ field.at('hello')
This plugin works best in conjunction with the errorsPlugin
. If the invalid field was associated
with an element
via ref
↗
than Field.scrollToError
↗
scrolls the viewport the reveal this element.
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
import scrollToErrorPlugin from 'roqueform/plugin/scroll-to-error';
const field = createField({ hello: 'world' }, [
errorsPlugin(),
scrollToErrorPlugin(),
]);
field.at('hello').ref(document.querySelector('input'));
field.at('hello').addError('Invalid value');
field.scrollToError();
// ⮕ field.at('hello')
If there are multiple invalid fields, use an index to scroll to a particular field:
const field = createField({ name: 'Bill', age: 5 }, [
errorsPlugin(),
scrollToErrorPlugin(),
]);
// Associate fields with DOM elements
field.at('name').ref(document.getElementById('#name'));
field.at('age').ref(document.getElementById('#age'));
// Add errors to fields
field.at('name').addError('Cannot be a nickname');
field.at('age').addError('Too young');
// 🟡 Scroll to the "age" field
field.scrollToError(1);
// ⮕ field.at('age')
uncontrolledPlugin
↗
updates fields by listening to change events of associated DOM elements.
import { createField } from 'roqueform';
import uncontrolledPlugin from 'roqueform/plugin/uncontrolled';
const field = createField({ hello: 'world' }, [uncontrolledPlugin()]);
field.at('hello').ref(document.querySelector('input'));
The plugin would synchronize the field value with the value of an input element.
If you have a set of radio buttons, or checkboxes that update a single field, call
Field.ref
multiple times providing each element. For example, let's use uncontrolledPlugin
to manage an array of animal species:
<input type="checkbox" value="Elephant" />
<input type="checkbox" value="Monkey" />
<input type="checkbox" value="Zebra" />
Create a field:
const field = createField({ animals: ['Zebra'] }, [uncontrolledPlugin()]);
Associate all checkboxes with a field:
document
.querySelectorAll('input[type="checkbox"]')
.forEach(field.at('animals').ref);
Right after checkboxes are associated, input with the value "Zebra" becomes checked. This happens because
the uncontrolledPlugin
updated the DOM to reflect the current state of the field.
If the user would check the "Elephant" value, then the field gets updated:
field.at('animals').value; // ⮕ ['Zebra', 'Elephant']
By default, uncontrolledPlugin
uses the opinionated element value accessor that applies following coercion rules to
values of form elements:
Elements | Value |
---|---|
Single checkbox | boolean , see checkboxFormat ↗. |
Multiple checkboxes | An array of value ↗ attributes of checked checkboxes, see checkboxFormat ↗. |
Radio buttons | The value ↗ attribute of a radio button that is checked or null if no radio buttons are checked. |
Number input | number , or null if empty. |
Range input | number |
Date input | The value ↗ attribute, or null if empty, see dateFormat ↗. |
Time input | A time string, or null if empty, see timeFormat ↗. |
Image input | A string value of the value ↗ attribute. |
File input | File ↗ or null if no file selected, file inputs are read-only. |
Multi-file input | An array of File ↗. |
Other | The value attribute, or null if element doesn't support it. |
null
, undefined
, NaN
and non-finite numbers are coerced to an empty string and written to value
attribute.
To change how values are read from and written to DOM, provide a custom
ElementsValueAccessor
↗
implementation to a plugin, or use a
createElementsValueAccessor
↗
factory to customise the default behaviour:
import { createField } from 'roqueform';
import uncontrolledPlugin, { createElementsValueAccessor } from 'roqueform/plugin/uncontrolled';
const myValueAccessor = createElementsValueAccessor({
dateFormat: 'timestamp',
});
const field = createField({ date: Date.now() }, [
uncontrolledPlugin(myValueAccessor),
]);
Read more about available options in
ElementsValueAccessorOptions
↗.
validationPlugin
↗
enhances fields with validation methods.
Tip
This plugin provides the low-level functionality. Have a look at
constraintValidationPlugin
or schemaPlugin
as
an alternative.
import { createField } from 'roqueform';
import validationPlugin from 'roqueform/plugin/validation';
const field = createField({ hello: 'world' }, [
validationPlugin(validation => {
// Validate the field value and return some result
return { ok: true };
}),
]);
The Validator
↗
callback receives
a Validation
↗
object that references a field where
Field.validate
↗
was called.
Any result returned from the validator callback, is returned from the Field.validate
method:
field.at('hello').validate();
// ⮕ { ok: boolean }
Validator may receive custom options so its behavior can be altered upon each Field.validate
call:
const field = createField({ hello: 'world' }, [
validationPlugin((validation, options: { coolStuff: string }) => {
// 1️⃣ Receive options in a validator
return options.coolStuff;
}),
]);
// 2️⃣ Pass options to the validator
field.validate({ coolStuff: 'okay' });
// ⮕ 'okay'
For asynchronous validation, provide a validator that returns a Promise
:
const field = createField({ hello: 'world' }, [
validationPlugin(async validation => {
// Do async validation here
await doSomeAsyncCheck(validation.field.value);
}),
]);
Check that async validation is pending:
field.isValidating; // ⮕ true
Abort the pending validation:
field.abortValidation();
When Field.validate
is called, it instantly aborts any pending validation associated with the field. Use
abortController
↗
to detect that a validation was cancelled:
const field = createField({ hello: 'world' }, [
validationPlugin(async validation => {
if (validation.abortController.signal.aborted) {
// Handle aborted validation here
}
}),
]);
field.validate();
// 🟡 Aborts pending validation
field.at('hello').validate();
Field.validate
sets
validation
↗
property for a field where it was called and to all of its descendants that hold a non-transient
value:
field.validate();
field.isValidating; // ⮕ true
field.at('hello').isValidating; // ⮕ true
Field.validate
doesn't trigger validation of the parent field:
field.at('hello').validate();
// 🟡 Parent field isn't validated
field.isValidating; // ⮕ false
field.at('hello').isValidating; // ⮕ true
Since each field can be validated separately, there can be multiple validations running in parallel. Validator callback can check that a particular field participates in a validation process:
const field = createField({ hello: 'world' }, [
validationPlugin(async validation => {
const helloField = validation.field.rootField.at('hello');
if (helloField.validation === validation) {
// helloField must be validated
}
}),
]);
The validation plugin doesn't provide a way to associate validation errors with fields since it only tracks validation
state. Usually, you should publish an event from a validator, so some other plugin handles
the field-error association. For example, use validationPlugin
in conjunction with
the errorsPlugin
:
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
import validationPlugin from 'roqueform/plugin/validation';
const field = createField({ hello: 'world' }, [
// 1️⃣ This plugin associates errors with fields
errorsPlugin<{ message: string }>(),
validationPlugin(validation => {
const helloField = validation.field.rootField.at('hello');
if (helloField.validation === validation && helloField.value.length < 10) {
// 2️⃣ This event is handled by the errorsPlugin
helloField.publish({
type: 'errorDetected',
target: helloField,
relatedTarget: validation.field,
payload: { message: 'Too short' }
});
}
}),
]);
field.at('hello').validate();
field.at('hello').errors;
// ⮕ [{ message: 'Too short' }]
Validation plugin publishes events when validation state changes:
field.subscribe(event => {
if (event.type === 'validationStarted') {
// Handle the validation state change
event.payload; // ⮕ Validation
}
});
schemaPlugin
↗
enhances fields with validation methods that use
Standard Schema instance to detect validation issues.
schemaPlugin
uses validationPlugin
under-the-hood, so events and validation semantics are
the exactly same.
Any validation library that supports Standard Schema can be used to create a schema object. Lets use Doubter ↗ as an example:
import * as d from 'doubter';
const helloSchema = d.object({
hello: d.string().max(5),
});
schemaPlugin
↗ publishes
errorDetected
events for fields that have validation issues. Use schemaPlugin
in conjunction
with errorsPlugin
to enable field-error association:
import * as d from 'doubter';
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
import schemaPlugin from 'roqueform/plugin/schema';
const field = createField({ hello: 'world' }, [
// 🟡 errorsPlugin handles Doubter issues
errorsPlugin<d.Issue>(),
schemaPlugin(helloSchema),
]);
The type of the field value is inferred from the provided shape, so the field value is statically checked.
When you call the
Field.validate
↗
method, it triggers validation of the field and all of its child fields:
// 🟡 Here an invalid value is set to the field
field.at('hello').setValue('universe');
field.validate();
// ⮕ { issues: [ … ] }
field.errors;
// ⮕ []
field.at('hello').errors;
// ⮕ [{ message: 'Must have the maximum length of 5', … }]
You can customize messages of validation issues detected by Doubter:
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
import schemaPlugin from 'roqueform/plugin/schema';
const arraySchema = d.array(d.string(), 'Expected an array').min(3, 'Not enough elements');
const field = createField(['hello', 'world'], [
errorsPlugin(),
schemaPlugin(arraySchema),
]);
field.validate(); // ⮕ false
field.errors;
// ⮕ [{ message: 'Not enough elements', … }]
Read more about error message localization ↗ with Doubter.
constraintValidationPlugin
↗
integrates fields with the
Constraint validation API ↗.
For example, let's use the plugin to validate text input:
<input type="text" required />
Create a new field:
import { createField } from 'roqueform';
import constraintValidationPlugin from 'roqueform/plugin/constraint-validation';
const field = createField({ hello: '' }, [
constraintValidationPlugin(),
]);
Associate the DOM element with the field:
field.at('hello').ref(document.querySelector('input'));
Check if field is invalid:
field.at('hello').isInvalid; // ⮕ true
field.at('hello').validity.valueMissing; // ⮕ true
Show an error message balloon for the first invalid element and get the field this element associated with:
field.reportValidity();
// ⮕ field.at('hello')
Get the array of all invalid fields:
field.getInvalidFields();
// ⮕ [field.at('hello')]
Subscribe to the field validity changes:
field.subscribe(event => {
if (event.type === 'validityChanged') {
event.target.validity; // ⮕ ValidityState
}
});
Roqueform has first-class React integration. To enable it, first install the integration package:
npm install --save-prod @roqueform/react
useField
↗ hook
has the same set of signatures
as createField
↗:
import { FieldRenderer, useField } from '@roqueform/react';
export function App() {
const rootField = useField({ hello: 'world' });
return (
<FieldRenderer field={rootField.at('hello')}>
{helloField => (
<input
type="text"
value={helloField.value}
onChange={event => helloField.setValue(event.target.value)}
/>
)}
</FieldRenderer>
);
}
useField
hook returns
a Field
↗ instance that
is preserved between re-renders.
The <FieldRenderer>
↗
component subscribes to the given field instance and re-renders children when an event is published by the field.
When a user updates the input value, the rootField.at('hello')
value is set and <FieldRenderer>
component
is re-rendered.
If you pass a callback as an initial value, it would be invoked when the field is initialized.
useField(() => getInitialValue());
Pass an array of plugins as the second argument of the useField
hook:
import { useField } from '@roqueform/react';
import errorsPlugin from 'roqueform/plugin/errors';
export function App() {
const field = useField({ hello: 'world' }, [errorsPlugin()]);
useEffect(() => {
field.addError('Invalid value');
}, []);
}
Let's consider the form with two <FieldRenderer>
elements. One of them renders the value of the root field and
the other one renders an input that updates the child field:
import { FieldRenderer, useField } from '@roqueform/react';
export function App() {
const rootField = useField({ hello: 'world' });
return (
<>
<FieldRenderer field={rootField}>
{field => JSON.stringify(field.value)}
</FieldRenderer>
<FieldRenderer field={rootField.at('hello')}>
{helloField => (
<input
type="text"
value={helloField.value}
onChange={event => helloField.setValue(event.target.value)}
/>
)}
</FieldRenderer>
</>
);
}
By default, <FieldRenderer>
component re-renders only when the provided field was updated directly, meaning updates
from ancestors or child fields would be ignored. So when user edits the input value, JSON.stringify
won't be
re-rendered.
Add the
isEagerlyUpdated
↗
property to force <FieldRenderer>
to re-render whenever its value was affected.
- <FieldRenderer field={rootField}>
+ <FieldRenderer
+ field={rootField}
+ isEagerlyUpdated={true}
+ >
{field => JSON.stringify(field.value)}
</FieldRenderer>
Now both fields are re-rendered when user edits the input text.
Use the
onChange
↗
handler that is triggered only when the field value was updated non-transiently.
<FieldRenderer
field={rootField.at('hello')}
onChange={value => {
// Handle the non-transient value changes
}}
>
{helloField => (
<input
type="text"
value={helloField.value}
onChange={event => helloField.setTransientValue(event.target.value)}
onBlur={field.flushTransient}
/>
)}
</FieldRenderer>
Roqueform was built to satisfy the following requirements:
-
Since the form lifecycle consists of separate phases (input, validate, display errors, and submit), the form state management library should allow to tap in (or at least not constrain the ability to do so) at any particular phase to tweak the data flow.
-
Form data should be statically and strictly typed up to the very field value setter. So there must be a compilation error if the string value from the silly input is assigned to the number-typed value in the form state object.
-
Use the platform! The form state management library must not constrain the use of the
form
submit behavior, browser-based validation, and other related native features. -
There should be no restrictions on how and when the form input is submitted because data submission is generally an application-specific process.
-
There are many approaches to validation, and a great number of awesome validation libraries. The form library must be agnostic to where (client-side, server-side, or both), how (on a field or on a form level), and when (sync, or async) the validation is handled.
-
Validation errors aren't standardized, so an arbitrary error object shape must be allowed and related typings must be seamlessly propagated to the error consumers/renderers.
-
The library API must be simple and easily extensible.