/roqueform

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

Primary LanguageTypeScriptMIT LicenseMIT

Roqueformβ€‚πŸ§€β€‚build

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

πŸ”₯ Try it on CodeSandbox

npm install --save-prod roqueform

Introduction

Form lifecycle consists of four separate phases: Input, Validate, Display errors, and Submit. These phases can be represented as non-intersecting processes. The result obtained during one phase may be used as an input for another phase. For example, let's consider the following set of actions:

  • The user inputs form values;
  • The input is validated;
  • Validation errors are displayed;
  • Input is submitted;
  • Errors received from the backend are displayed.

These actions are non-intersecting and can happen in an arbitrary order, or even in parallel. The form 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.

So the Roqueform was built to satisfy the following requirements:

  • No excessive re-renders of unchanged fields.

  • Everything 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 browser-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.

useField

The central piece of Roqueform is a useField hook that returns a Field object that represents a node in a tree of form input controllers:

import { useField } from 'roqueform';

const unconstrainedField = useField();
// β†’ Field<any, {}>

You can provide an initial value to a field:

const field = useField({ foo: 'bar' });
// β†’ Field<{ foo: string }, {}>

You can derive new fields from the existing ones using at method:

const fooField = field.at('foo');
// β†’ Field<string, {}>

fooField is a derived field, it is linked to the parent field. Fields returned by the at method have a stable identity, so you can invoke at with the same key multiple times and the same field instance would be returned:

field.at('foo') === field.at('foo') // β†’ true

Fields can be derived at any depth:

const field = useField({ foo: [{ bar: 'qux' }] });

field.at('foo').at(0).at('bar');
// β†’ Field<string, {}>

Field value updates

The field is essentially a container that encapsulates the value and provides methods to update it. Let's have a look at the dispatchValue method that updates the field value:

const field = useField({ foo: 'bar' });

field.getValue();
// β†’ { foo: 'bar' }

field.dispatchValue({ foo: 'qux' });

// The field value was updated
field.getValue();
// β†’ { foo: 'qux' }

useField doesn't trigger a re-render of the enclosing component. Navigate to Field observability section for more details.

When the parent field is updated using dispatchValue, all of the affected derived fields also receive an update:

const field = useField({ foo: 'bar' });
const fooField = field.at('foo');

field.getValue();
// β†’ { foo: 'bar' }

fooField.getValue();
// β†’ 'bar'

// Updating the root field
field.dispatchValue({ foo: 'qux' });

// The update was propagated to the derived field
field.getValue();
// β†’ { foo: 'qux' }

fooField.getValue();
// β†’ 'qux'

The same is valid for updating derived fields: when the derived field is updated using dispatchValue, the update is propagated to the parent field.

const field = useField({ foo: 'bar' });
const fooField = field.at('foo');

// Updating the derived field
fooField.dispatchValue('qux');

// The update was propagated to the parent field
field.getValue();
// β†’ { foo: 'qux' }

fooField.getValue();
// β†’ 'qux'

dispatchValue has a callback signature:

fooField.dispatchValue(prevValue => 'qux');

Transient updates

The field update can be done transiently, so the parent won't be notified. You can think about this as a commit in git: you first stage your changes with git add and then commit them with git commit.

To achieve this behavior we're going to use setValue/dispatch instead of dispatchValue that we discussed in Field value updates section:

const field = useField({ foo: 'bar' });
const fooField = field.at('foo');

// Set the transient value, "git add"
fooField.setValue('qux');

// 🟑 Notice that fooField was updated but field wasn't
field.getValue();
// β†’ { foo: 'bar' }

fooField.getValue();
// β†’ 'qux'

// Notify the parent, "git commit"
fooField.dispatch();

// Now both fields are in sync
field.getValue();
// β†’ { foo: 'qux' }

fooField.getValue();
// β†’ 'qux'

setValue can be called multiple times, but the most recent update would be propagated to the parent only after dispatch/dispatchValue call.

You can check that the field has a transient value using isTransient method:

const field = useField({ foo: 'bar' });
const fooField = field.at('foo');

fooField.setValue('qux');

fooField.isTransient();
// β†’ true

fooField.dispatch();

fooField.isTransient();
// β†’ false

Field observability

Fields are observable, you can subscribe to them and receive a callback whenever the field state is updated:

field.subscribe(targetField => {
  // Handle the update here
});

targetField is a field that initiated the update, so this can be field itself, any of its derived fields, or any of its ancestors (if field is also a derived field).

You can trigger all listeners that are subscribed to the field with notify:

field.notify();

Field

The Field component subscribes to the given field instance and re-renders its children when the field is updated:

import { Field, useField } from 'roqueform';

const App = () => {
  const rootField = useField('foo');

  return (
    <Field field={rootField}>
      {rootField => (
        <input
          value={rootField.getValue()}
          onChange={event => {
            rootField.dispatchValue(event.target.value);
          }}
        />
      )}
    </Field>
  );
};

Now, when a user would update the input value, the rootField would be updated. The single argument passed to children render function is the field passed as a field prop to the Field component.

It is unlikely that you would use a form with a single literal field. Most of the time multiple derived fields are required:

const App = () => {
  const rootField = useField({ foo: 'bar', bar: 123 });

  return <>
    <Field field={rootField.at('foo')}>
      {fooField => (
        <input
          type="text"
          value={fooField.getValue()}
          onChange={event => {
            fooField.dispatchValue(event.target.value);
          }}
        />
      )}
    </Field>

    <Field field={rootField.at('bar')}>
      {barField => (
        <input
          type="number"
          value={barField.getValue()}
          onChange={event => {
            barField.dispatchValue(event.target.valueAsNumber);
          }}
        />
      )}
    </Field>
  </>;
};

You may have noticed that even though we didn't specify any types yet, our fields are strictly typed. You can check this by replacing the value dispatched to barField:

- barField.dispatchValue(event.target.valueAsNumber);
+ barField.dispatchValue(event.target.value);

This would cause TypeScript to show an error that barField value must be of a number type.

Eager and lazy re-renders

Let's consider the form with two Field elements. One of them renders the value of the root field and the other one updates the derived field:

const App = () => {
  const rootField = useField({ bar: 'qux' });

  return <>
    <Field field={rootField}>
      {rootField => JSON.stringify(rootField.getValue())}
    </Field>

    <Field field={rootField.at('bar')}>
      {barField => (
        <input
          type="text"
          value={barField.getValue()}
          onChange={event => {
            barField.dispatchValue(event.target.value);
          }}
        />
      )}
    </Field>
  </>;
};

By default, Form component re-renders only when the provided field was updated directly, so updates from ancestors or derived fields would be ignored. Add the eagerlyUpdated property to force Field to re-render whenever its value was affected.

- <Field field={rootField}>
+ <Field
+   field={rootField}
+   eagerlyUpdated={true}
+ >
    {rootField => JSON.stringify(rootField.getValue())}
  </Field>

Now both fields are re-rendered when user edits the input text.

Reacting to changes

Subscribing to a field isn't always convenient. Instead, you can use an onChange handler that is triggered only when the field value was updated non-transiently.

<Field
  field={rootField.at('bar')}
  onChange={value => {
    // Handle the value change
  }}
>
  {barField => (
    <input
      type="text"
      value={barField.getValue()}
      onChange={event => {
        barField.dispatchValue(event.target.value);
      }}
    />
  )}
</Field>

Plugins

Plugins are a very powerful mechanism that allows enriching fields with custom functionality.

Let's enhance the field with the ref property that would hold the RefObject:

import { createRef } from 'react';
import { Plugin, useField } from 'roqueform';

const refPlugin: Plugin<any, { ref: RefObject<HTMLInputElement> }> = field => {
  return { ...field, ref: createRef() };
};

const rootField = useField({ bar: 'qux' }, refPlugin);
// β†’ Field<{ bar: string }, { ref: RefObject<HTMLInputElement> }> & { ref: RefObject<HTMLInputElement> }

The second argument of the useField hook is the plugin function that accepts a field instance and enriches it with the new functionality. In our case it adds the ref to each field derived from the rootField and to the rootField itself.

<Field field={rootField.at('bar')}>
  {barField => (
    <input
      // 🟑 Notice the ref property
      ref={barField.ref}
      value={barField.getValue()}
      onChange={event => {
        barField.dispatchValue(event.target.value);
      }}
    />
  )}
</Field>

After the Field mounts we can use ref to imperatively scroll the input element into view:

rootField.at('bar').ref.current?.scrollIntoView();

The ref plugin is available as a separate module @roqueform/ref-plugin:

import { useField } from 'roqueform';
import { refPlugin } from '@roqueform/ref-plugin';

const rootField = useField({ bar: 'qux' }, refPlugin<HTMLInputElement>());
// β†’ Field<{ bar: string }, RefPlugin<HTMLInputElement>> & RefPlugin<HTMLInputElement>

Composing plugins

You may want to use multiple plugins at the same time, but useField allows passing only one plugin function. To combine multiple plugins into one, use applyPlugins helper function:

import { applyPlugins, useField } from 'roqueform';
import { refPlugin } from '@roqueform/ref-plugin';

const field = useField({ bar: 'qux' }, applyPlugins(refPlugin(), anotherPlugin));

Form submission

Without plugins, Roqueform only manages the state of the form fields, and doesn't affect how the form is submitted. So you can use form tags as you did before, but read input values from the Field object:

const App = () => {
  const rootField = useField({ bar: 'foo' });

  const handleSubmit = (event: SyntheticEvent): void => {
    event.preventDefault();

    // The form value to submit
    const value = rootField.getValue();
  };

  return (
    <form onSubmit={handleSubmit}>

      <Field field={rootField.at('bar')}>
        {barField => (
          <input
            value={barField.getValue()}
            onChange={event => {
              barField.dispatchValue(event.target.value);
            }}
          />
        )}
      </Field>

      <button type="submit">
        {'Submit'}
      </button>

    </form>
  );
};

You can always create a plugin that would enhance the Field with custom submit mechanics.

Validation

Roqueform isn't tied to any validation library. You can use an existing plugin, or write your own to extend Roqueform with validation provided by an arbitrary library.

Currently, the only available validation plugin @roqueform/doubter-plugin which uses Doubter under-the-hood.

import { useField } from 'roqueform';
import { doubterPlugin } from '@roqueform/doubter-plugin';
import * as d from 'doubter';

const valueType = d.object({
  bar: d.string().min(5)
});

const rootField = useField({ bar: 'qux' }, doubterPlugin(valueType));

rootField.validate();

rootField.at('bar').getIssue();
// β†’ { message: 'Must have the minimum length of 5', … }

Plugin usage details can be found here.

Accessors

When you derive a new field or update a derived field, Roqueform uses an accessor to read and write a value to the parent field. You can alter the way field values are read and written by providing a custom implementation of Accessor interface to AccessorContext.

import { objectAccessor, AccessorContext } from 'roqueform';

<AccessorContext.Provider value={objectAccessor}>
  {/* useField should go here */}
</AccessorContext.Provider>