The form state management library that can handle hundreds of fields without breaking a sweat.
npm install roqueformThe 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. ↗
``sh`
npm install --save-prod roqueform
Features
- Introduction
- Events and subscriptions
- Transient updates
- Accessors
- Plugins
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
- Eager and lazy re-renders
- Reacting to changes
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:
`ts
import { createField } from 'roqueform';
const field = createField();
// ⮕ Field
`
A value can be set to and retrieved from the field:
`ts
field.setValue('Pluto');
field.value; // ⮕ 'Pluto'
`
Provide the initial value for a field:
`ts
const ageField = createField(42);
// ⮕ Field
ageField.value; // ⮕ 42
`
The field value type is inferred from the initial value, but you can explicitly specify the field value type:
`ts
interface Planet {
name: string;
}
interface Universe {
planets: Planet[];
}
const universeField = createField
// ⮕ Field
universeField.value; // ⮕ undefined
`
Retrieve a child field by its key:
`ts`
const planetsField = universeField.at('planets');
// ⮕ Field
planetsField is a child field, and it is linked to its parent universeField.
`ts
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:
`ts`
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:
`ts`
planetsField.at(0).at('name');
// ⮕ Field
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.
`ts
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:
`ts
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:
`ts`
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.
`ts
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 byerrorsPlugin
the .
`ts
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:
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.
`ts
const field = createField();
// ⮕ Field
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:
`ts`
field.at('hello').isTransient; // ⮕ true
To propagate the transient value contained by the child field to its parent, use the
Field.flushTransient ↗
method:
`ts
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:
`ts
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:
`ts
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 the
ValueAccessor.get ↗
method.
- When a field value is updated via
Field.setValue ↗,
then the parent field value is updated with the value returned from the
ValueAccessor.set ↗
method. If the updated field has child fields, their values are updated with values returned from the
ValueAccessor.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:
`ts
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:
`ts
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.
`ts
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:
`ts
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 ↗:
`ts
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.
`ts
import { FieldPlugin } from 'roqueform';
interface MyValue {
hello: string;
}
interface MyMixin {
element: Element | null;
}
const myPlugin: FieldPlugin
// 🟡 Initialize mixin properties
field.element = null;
};
`
To apply the plugin to a field, pass it to the field factory:
`ts
const field = createField({ hello: 'world' }, [myPlugin]);
// ⮕ Field
field.element; // ⮕ null
`
The plugin is applied to the field itself and its descendants when they are accessed for the first time:
`ts`
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:
`ts
import { FieldPlugin } from 'roqueform';
interface MyMixin {
element: Element | null;
setElement(element: Element | null): void;
}
const myPlugin: FieldPlugin
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:
`ts
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.
`ts
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:
`ts
field.annotate({ isDisabled: true });
field.annotations.isDisabled; // ⮕ true
field.at('hello').annotations.isDisabled; // ⮕ false
`
Annotate field and all of its children recursively:
`ts
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:
`ts`
field.annotate(
field => {
// Toggle isDisabled for the field and its descendants
return { isDisabled: !field.annotations.isDisabled };
},
{ isRecursive: true }
);
Subscribe to annotation changes:
`ts`
field.subscribe(event => {
if (event.type === 'annotationsChanged') {
event.target.annotations; // ⮕ { isDisabled: boolean }
}
});
errorsPlugin ↗ associates
errors with fields:
`ts
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
const field = createField({ hello: 'world' }, [errorsPlugin
field.at('hello').addError('Invalid value');
`
Read errors associated with the field:
`ts`
field.at('hello').errors;
// ⮕ ['Invalid value']
Check that the field has associated errors:
`ts`
field.at('hello').isInvalid; // ⮕ true
Get all fields that have associated errors:
`ts`
field.getInvalidFields();
// ⮕ [field.at('hello')]
Delete an error from the field:
`ts`
field.at('hello').deleteError('Invalid value');
Clear all errors from the field and its descendants:
`ts`
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:
`ts
interface MyError {
message: string;
}
const field = createField({ hello: 'world' }, [
errorsPlugin
]);
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.message
Otherwise, if an error isn't an object or doesn't have a field, then it is added only if it has a unique
identity. To override this behavior, provide an error concatenator callback:
`ts
import { createField } from 'roqueform';
import errorsPlugin from 'roqueform/plugin/errors';
const field = createField({ hello: 'world' }, [
errorsPlugin
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:
`ts
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:
`ts`
field.subscribe(event => {
if (event.type === 'errorAdded') {
event.target.errors; // ⮕ MyError[]
}
});
refPlugin ↗ associates DOM
elements with fields.
`ts
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:
`ts`
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.
`ts
field.at('hello').focus();
field.at('hello').isFocused; // ⮕ true
`
Scroll to an element:
`ts`
field.at('hello').scrollIntoView({ behavior: 'smooth' });
resetPlugin ↗ enhances fields
with methods that manage the initial value.
`ts
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:
`ts
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 ↗.
`ts
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:
`ts`
field.getDirtyFields();
// ⮕ [field, field.at('hello')]
Subscribe to initial value changes:
`ts`
field.subscribe(event => {
if (event.type === 'initialValueChanged') {
event.target.initialValue;
}
});
scrollToErrorPlugin ↗
enhances the field with methods to scroll to the closest invalid field.
`ts
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.
`ts
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:
`ts
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.
`ts
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:
`html`
Create a field:
`ts`
const field = createField({ animals: ['Zebra'] }, [uncontrolledPlugin()]);
Associate all checkboxes with a field:
`ts`
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:
`ts`
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 ↗. |value
| Multiple checkboxes | An array of ↗ attributes of checked checkboxes, see checkboxFormat ↗. |value
| Radio buttons | The ↗ attribute of a radio button that is checked or null if no radio buttons are checked. |number
| Number input | , or null if empty. |number
| Range input | |value
| Date input | The ↗ attribute, or null if empty, see dateFormat ↗. |null
| Time input | A time string, or if empty, see timeFormat ↗. |value
| Image input | A string value of the ↗ attribute. |File
| File input | ↗ or null if no file selected, file inputs are read-only. |File
| Multi-file input | An array of ↗. |value
| Other | The 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:
`ts
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.
`ts
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:
`ts`
field.at('hello').validate();
// ⮕ { ok: boolean }
Validator may receive custom options so its behavior can be altered upon each Field.validate call:
`ts
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:
`ts`
const field = createField({ hello: 'world' }, [
validationPlugin(async validation => {
// Do async validation here
await doSomeAsyncCheck(validation.field.value);
}),
]);
Check that async validation is pending:
`ts`
field.isValidating; // ⮕ true
Abort the pending validation:
`ts`
field.abortValidation();
When Field.validate is called, it instantly aborts any pending validation associated with the field. UseabortController ↗
to detect that a validation was cancelled:
`ts
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 setsvalidation ↗
property for a field where it was called and to all of its descendants that hold a non-transient
value:
`ts
field.validate();
field.isValidating; // ⮕ true
field.at('hello').isValidating; // ⮕ true
`
Field.validate doesn't trigger validation of the parent field:
`ts
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:
`ts`
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 witherrorsPlugin
the :
`ts
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:
`ts`
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:
`ts
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 conjunctionerrorsPlugin
with to enable field-error association:
`ts
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
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:
`ts
// 🟡 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:
`ts
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:
`html`
Create a new field:
`ts
import { createField } from 'roqueform';
import constraintValidationPlugin from 'roqueform/plugin/constraint-validation';
const field = createField({ hello: '' }, [
constraintValidationPlugin(),
]);
`
Associate the DOM element with the field:
`ts`
field.at('hello').ref(document.querySelector('input'));
Check if field is invalid:
`ts
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:
`ts`
field.reportValidity();
// ⮕ field.at('hello')
Get the array of all invalid fields:
`ts`
field.getInvalidFields();
// ⮕ [field.at('hello')]
Subscribe to the field validity changes:
`ts`
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:
`sh`
npm install --save-prod @roqueform/react
useField ↗ hook
has the same set of signatures
as createField ↗:
`tsx
import { FieldRenderer, useField } from '@roqueform/react';
export function App() {
const rootField = useField({ hello: 'world' });
return (
{helloField => (
type="text"
value={helloField.value}
onChange={event => helloField.setValue(event.target.value)}
/>
)}
);
}
`
useField hook returnsField
a ↗ instance that
is preserved between re-renders.
The ↗
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 component
is re-rendered.
If you pass a callback as an initial value, it would be invoked when the field is initialized.
`ts`
useField(() => getInitialValue());
Pass an array of plugins as the second argument of the useField hook:
`ts
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 elements. One of them renders the value of the root field and
the other one renders an input that updates the child field:
`tsx
import { FieldRenderer, useField } from '@roqueform/react';
export function App() {
const rootField = useField({ hello: 'world' });
return (
<>
{field => JSON.stringify(field.value)}
{helloField => (
type="text"
value={helloField.value}
onChange={event => helloField.setValue(event.target.value)}
/>
)}
>
);
}
`
By default, component re-renders only when the provided field was updated directly, meaning updatesJSON.stringify
from ancestors or child fields would be ignored. So when user edits the input value, won't be
re-rendered.
Add the
isEagerlyUpdated ↗
property to force to re-render whenever its value was affected.
`diff`
-
+
+ isEagerlyUpdated={true}
+ >
{field => JSON.stringify(field.value)}
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.
`tsx`
onChange={value => {
// Handle the non-transient value changes
}}
>
{helloField => (
type="text"
value={helloField.value}
onChange={event => helloField.setTransientValue(event.target.value)}
onBlur={field.flushTransient}
/>
)}
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.