Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/smikhalevski/roqueform
🧀 The form state management library that can handle hundreds of fields without breaking a sweat.
https://github.com/smikhalevski/roqueform
field form plugin validation
Last synced: about 2 months ago
JSON representation
🧀 The form state management library that can handle hundreds of fields without breaking a sweat.
- Host: GitHub
- URL: https://github.com/smikhalevski/roqueform
- Owner: smikhalevski
- License: mit
- Created: 2022-01-17T20:00:25.000Z (almost 3 years ago)
- Default Branch: master
- Last Pushed: 2024-10-25T11:48:42.000Z (2 months ago)
- Last Synced: 2024-11-08T23:37:37.993Z (about 2 months ago)
- Topics: field, form, plugin, validation
- Language: TypeScript
- Homepage: https://smikhalevski.github.io/roqueform/
- Size: 1.69 MB
- Stars: 4
- Watchers: 3
- Forks: 1
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE.txt
Awesome Lists containing this project
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;](#plugins-and-integrations)
- Supports your favourite rendering and [validation libraries;](#validation-scaffolding-plugin)
- [Just 2 kB gzipped.](https://bundlephobia.com/result?p=roqueform)🔥 [**Try it on CodeSandbox**](https://codesandbox.io/s/2evfif)
```sh
npm install --save-prod roqueform
```- [Plugins and integrations](#plugins-and-integrations)
- [Core features](#core-features)
- [Events and subscriptions](#events-and-subscriptions)
- [Transient updates](#transient-updates)
- [Accessors](#accessors)
- [Authoring a plugin](#authoring-a-plugin)
- [Composing plugins](#composing-plugins)
- [Errors plugin](#errors-plugin)
- [Validation scaffolding plugin](#validation-scaffolding-plugin)
- [Motivation](#motivation)# Plugins and integrations
- [react](./packages/react#readme)
Hooks and components to integrate with React.- [constraint-validation-plugin](./packages/constraint-validation-plugin#readme)
Integrates fields with
[Constraint validation API](https://developer.mozilla.org/en-US/docs/Web/API/Constraint_validation).- [doubter-plugin](./packages/doubter-plugin#readme)
Validates fields with [Doubter](https://github.com/smikhalevski/doubter#readme) shapes.- [ref-plugin](./packages/ref-plugin#readme)
Associates field with DOM elements.- [reset-plugin](./packages/reset-plugin#readme)
Manages field initial value and dirty status.- [scroll-to-error-plugin](./packages/scroll-to-error-plugin#readme)
Enables scrolling to a field that has an associated validation error. Works with any validation plugin in this repo.- [uncontrolled-plugin](./packages/uncontrolled-plugin#readme)
Updates fields by listening to change events of associated DOM elements.- [zod-plugin](./packages/zod-plugin#readme)
Validates fields with [Zod](https://zod.dev/) schemas.- [annotations-plugin](./packages/annotations-plugin#readme)
Enables custom annotations and metadata for fields.# Core features
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);
// ⮕ FieldageField.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();
// ⮕ FielduniverseField.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`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#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:```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;
// ⮕ undefineduniverseField.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](#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'
```# Events and subscriptions
You can subscribe events dispatched onto the field.
```ts
const unsubscribe = planetsField.on('change:value', event => {
// Handle the field value change
});
// ⮕ () => void
```The [`Field.on`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#on) method
associates the event subscriber with an event type. All events that are dispatched onto fields have the share
[`Event`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Event.html).Without plugins, fields can dispatch events with
[`change:value`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#on.on-2) type. This
event is dispatched when the field value is changed via
[`Field.setValue`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#setValue).Plugins may dispatch their own events. Here's an example of the
[`change:errors`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.ErrorsPlugin.html#on.on-1) event
introduced by the [`errorsPlugin`](#errors-plugin).```ts
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](https://262.ecma-international.org/7.0/#sec-samevaluezero) comparison to detect that the value has changed.```ts
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:
```ts
planetsField.on('*', event => {
// Handle all events
});
```# Transient updates
When you call [`Field.setValue`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#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.
```ts
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:
```ts
avatarField.at('eyeColor').isTransient;
// ⮕ true
```To propagate the transient value contained by the child field to its parent, use the
[`Field.propagate`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#propagate)
method:```ts
avatarField.at('eyeColor').propagate();avatarField.value;
// ⮕ { eyeColor: 'green' }
```[`Field.setTransientValue`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#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:
```ts
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:
```ts
planetsField.at(1).propagate();planetsField.value[1];
// ⮕ 'Venus'planetsField.at(1).value;
// ⮕ 'Venus'
```# Accessors
[`ValueAccessor`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.ValueAccessor.html) creates, reads and
updates field values.- When the child field is accessed via
[`Field.at`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#at) method for the
first time, its value is read from the value of the parent field using the
[`ValueAccessor.get`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#get) method.- When a field value is updated via
[`Field.setValue`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.BareField.html#setValue), then
the parent field value is updated with the value returned from the
[`ValueAccessor.set`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#set) method. If the
updated field has child fields, their values are updated with values returned from the
[`ValueAccessor.get`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#get) method.You can explicitly provide a custom accessor along with the initial value. Be default, Roqueform uses
[`naturalValueAccessor`](https://smikhalevski.github.io/roqueform/variables/roqueform.naturalValueAccessor.html):```ts
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:
```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, 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.```ts
const carsField = createField();carsField.at(0).at('brand').setValue('Ford');
carsField.value;
// ⮕ [{ brand: 'Ford' }]
```# Authoring a plugin
Plugins are applied to a field using a
[`PluginInjector`](https://smikhalevski.github.io/roqueform/types/roqueform.PluginInjector.html) 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.```ts
import { PluginInjector } from 'roqueform';interface ElementPlugin {
element: Element | null;
}const injectElementPlugin: PluginInjector = field => {
// Update field with plugin functionality
field.element = null;
};
```To apply the plugin to a field, pass it to the field factory:
```ts
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:```ts
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.```ts
import { PluginInjector, dispatchEvents } from 'roqueform';interface ElementPlugin {
element: Element | null;setElement(element: Element | null): void;
}const injectElementPlugin: PluginInjector = 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`](https://smikhalevski.github.io/roqueform/functions/roqueform.dispatchEvents.html) helper
that invokes subscribers for provided events. So when `setElement` is called on a field, its subscribers would be
notified about element changes:```ts
const planetField = createField(
{ name: 'Mars' },
injectElementPlugin
);planetField.at('name').on('changed:element', event => {
event.targetField.element;
// ⮕ document.body
});planetField.at('name').setElement(document.body);
```# Composing plugins
To combine multiple plugins into a single function, use the
[`composePlugins`](https://smikhalevski.github.io/roqueform/functions/roqueform.composePlugins.html) helper:```ts
import { createField, composePlugins } from 'roqueform';createField(['Mars'], composePlugins(plugin1, plugin2));
// ⮕ Field
```# Errors plugin
Roqueform is shipped with the plugin that allows to associate errors with fields
[`errorsPlugin`](https://smikhalevski.github.io/roqueform/functions/roqueform.errorsPlugin.html).```ts
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:
```ts
userField.getInvalidFields();
// ⮕ [userField.at('name')]
```# Validation scaffolding plugin
Roqueform is shipped with the validation scaffolding plugin
[`validationPlugin`](https://smikhalevski.github.io/roqueform/functions/roqueform.validationPlugin.html), so you can
build your validation on top of it.> [!NOTE]\
> This plugin provides a low-level functionality. Prefer
> [constraint-validation-plugin](./packages/constraint-validation-plugin), [doubter-plugin](./packages/doubter-plugin),
> or [zod-plugin](./packages/zod-plugin) or other high-level validation plugin.```ts
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();
// ⮕ falseuserField.at('name').isInvalid;
// ⮕ true
```The plugin takes a [`Validator`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Validator.html) 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](#errors-plugin). The latter would update
`isInvalid` when an error is added or deleted:```ts
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();
// ⮕ falseuserField.at('name').isInvalid;
// ⮕ trueuserField.at('name').errors;
// ⮕ ['Must not be blank']
```# 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 `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.