Skip to content

🧀 The form state management library that can handle hundreds of fields without breaking a sweat.

License

Notifications You must be signed in to change notification settings

smikhalevski/roqueform

Repository files navigation

Roqueform

The form state management library that can handle hundreds of fields without breaking a sweat.

🔥 Try it on CodeSandbox

npm install --save-prod roqueform

Plugins and integrations

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:

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'

Events and subscriptions

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
});

Transient updates

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'

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 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.

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' }]

Authoring a plugin

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);

Composing plugins

To combine multiple plugins into a single function, use the composePlugins helper:

import { createField, composePlugins } from 'roqueform';

createField(['Mars'], composePlugins(plugin1, plugin2));
// ⮕ Field<string[], …>

Errors plugin

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')]

Validation scaffolding plugin

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']

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.

About

🧀 The form state management library that can handle hundreds of fields without breaking a sweat.

Topics

Resources

License

Stars

Watchers

Forks