roqueform
v6.1.0
Published
The form state management library that can handle hundreds of fields without breaking a sweat.
Downloads
116
Maintainers
Readme
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
Introduction
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; // ⮕ 42The 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; // ⮕ undefinedRetrieve 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; // ⮕ universeFieldFields 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');
// ⮕ planetsFieldSo 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'Events and subscriptions
You can subscribe to events published by a field:
const unsubscribe = planetsField.subscribe(event => {
if (event.type === 'valueChanged') {
// Handle the field value change
}
});
// ⮕ () => voidAll 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:
Transient updates
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; // ⮕ undefinedYou can check that a field is in a transient state:
field.at('hello').isTransient; // ⮕ trueTo 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'Accessors
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);Plugins
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; // ⮕ nullThe plugin is applied to the field itself and its descendants when they are accessed for the first time:
field.at('hello').element; // ⮕ nullPlugins 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);Annotations plugin
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; // ⮕ falseUpdate annotations for a single field:
field.annotate({ isDisabled: true });
field.annotations.isDisabled; // ⮕ true
field.at('hello').annotations.isDisabled; // ⮕ falseAnnotate 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; // ⮕ trueAnnotations 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 }
}
});Errors plugin
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; // ⮕ trueGet 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[]
}
});DOM element reference plugin
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 | nullFocus 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; // ⮕ trueScroll to an element:
field.at('hello').scrollIntoView({ behavior: 'smooth' });Reset plugin
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; // ⮕ trueGet 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;
}
});Scroll to error plugin
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')Uncontrolled plugin
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']Value coercion
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 ↗.
Validation plugin
validationPlugin ↗
enhances fields with validation methods.
[!TIP]
This plugin provides the low-level functionality. Have a look atconstraintValidationPluginorschemaPluginas 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; // ⮕ trueAbort 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; // ⮕ trueField.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; // ⮕ trueSince 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
}
});Schema plugin
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', … }]Custom error messages
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.
Constraint validation API plugin
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; // ⮕ trueShow 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
}
});React integration
Roqueform has first-class React integration. To enable it, first install the integration package:
npm install --save-prod @roqueform/reactuseField ↗ 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');
}, []);
}Eager and lazy re-renders
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.
Reacting to changes
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>Motivation
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
formsubmit 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.
