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;
- Supports your favourite rendering and validation libraries;
- Just 2 kB gzipped.
🔥 Try it on CodeSandbox
npm install --save-prod roqueform
- Plugins and integrations
- Core features
- Events and subscriptions
- Transient updates
- Accessors
- Authoring a plugin
- Composing plugins
- Errors plugin
- Validation scaffolding plugin
- Motivation
-
react
Hooks and components to integrate with React. -
constraint-validation-plugin
Integrates fields with Constraint validation API. -
doubter-plugin
Validates fields with Doubter shapes. -
ref-plugin
Associates field with DOM elements. -
reset-plugin
Manages field initial value and dirty status. -
scroll-to-error-plugin
Enables scrolling to a field that has an associated validation error. Works with any validation plugin in this repo. -
uncontrolled-plugin
Updates fields by listening to change events of associated DOM elements. -
zod-plugin
Validates fields with Zod schemas. -
annotations-plugin
Enables custom annotations and metadata for fields.
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
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>
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 events dispatched onto the field.
const unsubscribe = planetsField.on('change:value', event => {
// Handle the field value change
});
// ⮕ () => void
The Field.on
method
associates the event subscriber with an event type. All events that are dispatched onto fields have the share
Event
.
Without plugins, fields can dispatch events with
change:value
type. This
event is dispatched when the field value is changed via
Field.setValue
.
Plugins may dispatch their own events. Here's an example of the
change:errors
event
introduced by the errorsPlugin
.
import { createField, errorsPlugin } from 'roqueform';
const field = createField({ name: 'Bill' }, errorsPlugin());
field.on('change:errors', event => {
// Handle error change
});
field.addError('Illegal user');
The root field and all child fields are updated before change:value
subscribers are called, so it's safe to read field
values in a subscriber. Fields use SameValueZero comparison to detect that the value has changed.
planetsField.at(0).at('name').on('change:value', subscriber);
// ✅ The subscriber is called
planetsField.at(0).at('name').setValue('Mercury');
// 🚫 Value is unchanged, the subscriber isn't called
planetsField.at(0).setValue({ name: 'Mercury' });
Subscribe to all events dispatched onto the field using the glob event type:
planetsField.on('*', event => {
// Handle all events
});
When you call Field.setValue
on a field then subscribers of its ancestors and its updated child fields are triggered. 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 avatarField = createField();
avatarField.at('eyeColor').setTransientValue('green');
avatarField.at('eyeColor').value;
// ⮕ 'green'
// 🟡 Parent value wasn't updated
avatarField.value;
// ⮕ undefined
You can check that a field is in a transient state:
avatarField.at('eyeColor').isTransient;
// ⮕ true
To propagate the transient value contained by the child field to its parent, use the
Field.propagate
method:
avatarField.at('eyeColor').propagate();
avatarField.value;
// ⮕ { eyeColor: 'green' }
Field.setTransientValue
can be called multiple times, but only the most recent update is propagated to the parent field after the propagate
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.value[1];
// ⮕ 'Pluto'
// 🟡 Transient value isn't visible from the parent
planetsField.at(1).value;
// ⮕ 'Venus'
Values are synchronized after the update is propagated:
planetsField.at(1).propagate();
planetsField.value[1];
// ⮕ 'Venus'
planetsField.at(1).value;
// ⮕ '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.
You can explicitly provide a custom accessor along with the initial value. Be default, Roqueform uses
naturalValueAccessor
:
import { createField, naturalValueAccessor } from 'roqueform';
const field = createField(['Mars', 'Venus'], naturalValueAccessor);
naturalValueAccessor
supports:
- plain objects,
- class instances,
- arrays,
Map
-like,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, a parent field value is inferred from the 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' }]
Plugins are applied to a field using a
PluginInjector
callback. This callback
receives a mutable plugin instance and should enrich it with the plugin functionality. To illustrate how plugins work,
let's create a simple plugin that enriches a field with a DOM element reference.
import { PluginInjector } from 'roqueform';
interface ElementPlugin {
element: Element | null;
}
const injectElementPlugin: PluginInjector<ElementPlugin> = field => {
// Update field with plugin functionality
field.element = null;
};
To apply the plugin to a field, pass it to the field factory:
const planetField = createField(
{ name: 'Mars' },
injectElementPlugin
);
// ⮕ Field<{ name: string }, { element: Element | null }>
planetField.element;
// ⮕ null
The plugin is applied to the planetField
itself and each of its child fields when they are accessed for the first
time:
planetField.at('name').element
// ⮕ null
We can now assign a DOM element reference to an element
property, so we can later access an element through a field.
Plugins may dispatch custom events. Let's update the plugin implementation to notify subscribers that the element has changed.
import { PluginInjector, dispatchEvents } from 'roqueform';
interface ElementPlugin {
element: Element | null;
setElement(element: Element | null): void;
}
const injectElementPlugin: PluginInjector<ElementPlugin> = field => {
field.element = null;
field.setElement = element => {
if (field.element !== element) {
field.element = element;
// Synchronously trigger associated subscribers
dispatchEvents([{
type: 'changed:element',
targetField: field,
originField: field,
data: null
}]);
}
};
};
Here we used dispatchEvents
helper
that invokes subscribers for provided events. So when setElement
is called on a field, its subscribers would be
notified about element changes:
const planetField = createField(
{ name: 'Mars' },
injectElementPlugin
);
planetField.at('name').on('changed:element', event => {
event.targetField.element;
// ⮕ document.body
});
planetField.at('name').setElement(document.body);
To combine multiple plugins into a single function, use the
composePlugins
helper:
import { createField, composePlugins } from 'roqueform';
createField(['Mars'], composePlugins(plugin1, plugin2));
// ⮕ Field<string[], …>
Roqueform is shipped with the plugin that allows to associate errors with fields
errorsPlugin
.
import { errorsPlugin } from 'roqueform';
const userField = createField({ name: '' }, errorsPlugin());
userField.at('name').addError('Too short');
userField.at('name').errors;
// ⮕ ['Too short']
Get all invalid fields:
userField.getInvalidFields();
// ⮕ [userField.at('name')]
Roqueform is shipped with the validation scaffolding plugin
validationPlugin
, so you can
build your validation on top of it.
Note
This plugin provides a low-level functionality. Prefer constraint-validation-plugin, doubter-plugin, or zod-plugin or other high-level validation plugin.
import { validationPlugin } from 'roqueform';
const plugin = validationPlugin({
validate(field) {
if (!field.at('name').value) {
field.at('name').isInvalid = true;
}
}
});
const userField = createField({ name: '' }, plugin);
userField.validate();
// ⮕ false
userField.at('name').isInvalid;
// ⮕ true
The plugin takes a Validator
that has
validate
and validateAsync
methods. Both methods receive a field that must be validated and should update the
isInvalid
property of the field or any of its children when needed.
Validation plugin works best in conjunction with the errors plugin. The latter would update
isInvalid
when an error is added or deleted:
import { validationPlugin } from 'roqueform';
const plugin = validationPlugin(
// Make errors plugin available inside the validator
errorsPlugin(),
{
validate(field) {
if (!field.at('name').value) {
// Add an error to the invalid field
field.at('name').addError('Must not be blank')
}
}
}
);
const userField = createField({ name: '' }, plugin);
userField.validate();
// ⮕ false
userField.at('name').isInvalid;
// ⮕ true
userField.at('name').errors;
// ⮕ ['Must not be blank']
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.