react-cool-forms

Powerful and flexible forms for React ?

Dark

Features

  • ? Real flexible API
  • ? UI library agnostic
  • ? Conditional fields
  • ? Array fields
  • ? Form-level validation
  • ? Field-level validation
  • ? Dependent validation
  • ? Custom validators
  • ? onChange validation support
  • ? Async validators support
  • ⚡️ Output formatters support
  • ? Extremal performance
  • ? Small size (6 Kb gzipped)
  • ✔️ No dependencies

Demo

Motivation

I have been looking for a form validation library for React in the npm repository for a long time, but all of them did not suit me for one reason or another. First of all, I lacked the flexibility of the API of these libraries. Most of them are based on the use of simple HTML inputs and the like. But writing complex forms is not limited to HTML inputs or checkboxes or something else. Having a lot of experience with forms at my former company, I decided to write this library. The main message: any arbitrarily complex component that implements the value / onChange interface can be a full member of the form and pass the required validation. I also combined the classic Observer pattern along with the React Context and React memoization to get the best input performance without debounce technique (tested 3000 inputs in one form).

Installation

npm:

npm install react-cool-forms

yarn:

yarn add react-cool-forms

Usage

import { Form, Field } from 'react-cool-forms';

const required = {
  method: ({ fieldValue }) => Boolean(fieldValue),
  message: `It's required field`,
};

const handleSubmit = x => console.log('submit: ', x);
const getFirstName = form => form.firstName;
const setFirstName = (form, value) => (form.firstName = value);

<Form initialFormValue={{ firstName: '' }} onSubmit={handleSubmit}>
  {({ submit }) => {
    return (
      <>
        <Field
          name='firstName'
          getValue={getFirstName}
          setValue={setFirstName}
          validators={[required]}>
          {({ value, error, onChange }) => {
            return (
              <label>
                First name:
                <input value={value} onChange={e => onChange(e.target.value)} />
                {error && <div style={{ color: 'red' }}>{error}</div>}
              </label>
            );
          }}
        </Field>
        <button onClick={submit}>Submit</button>
      </>
    );
  }}
</Form>

API

import {
  Form,
  Field,
  Repeater,
  Debugger,
  useFormState,
  type Validator,
  type Formatter,
} from 'react-cool-forms';

Form

This is the root component that contains the root react context. Forms can be nested inside each other if you need it for some reason. The main properties that a Form takes are the initialization object and the onSubmit callback.

import { Form } from 'react-cool-forms';

<Form initialFormValue={{}} onSubmit={handleSubmit}>
  {({ formValue, errors, inProcess, submit, reset }) => <div>Some children here...</div>}
</Form>

type FormProps<T> = {
  initialFormValue: T;
  connectedRef?: React.Ref<FormRef<T>>;
  interrupt?: boolean;
  validators?: Array<Validator<T, T>>;
  onValidate?: (options: OnValidateOptions<T>) => void;
  onChange?: (options: OnChangeOptions<T>) => void;
  onSubmit: (options: OnSubmitOptions<T>) => void;
  children: (options: FormChildrenOptions<T>) => React.ReactElement;
};

FormProps

prop required description
initialFormValue Initialization object.
connectedRef Ref for imperative access to main methods.
interrupt Indicates whether to stop validation on the first error or not.
validators Array of root validators.
onValidate Called every time during validation.
onChange Called every time formValue changes.
onSubmit Called after successful validation of the entire form.
children Render function that takes options (FormChildrenOptions).

type FormRef<T> = {
  getFormValue: () => T;
  modify: (formValue: T) => void;
  validate: (formValue: T) => Promise<boolean>;
  submit: () => void;
  reset: () => void;
};

type FormChildrenOptions<T> = {
  formValue: T;
  errors: Record<string, string>;
  inProcess: boolean;
  validate: (formValue: T) => Promise<boolean>;
  submit: () => void;
  reset: () => void;
};

FormChildrenOptions

prop description
formValue Actual value of form.
errors Object with all validation errors.
inProcess Shows if we are in the process of validation.
validate Manual validation start if you need it. For example, validation before moving to the next step of the wizard.
submit Call it for validation and submit form.
reset Reset formValue to initialFormValue.

type OnValidateOptions<T> = {
  formValue: T;
  isValid: boolean;
  errors: Record<string, string> | null;
};

type OnChangeOptions<T> = {
  formValue: T;
};

type OnSubmitOptions<T> = {
  formValue: T;
};

Field

This is a component that renders a single form element, such as input. Since the component does not impose any restrictions on how the form element should look, it expects to receive a render function as children that will display it.

import { Field } from 'react-cool-forms';

<Field
  name='name'
  getValue={(person: Person) => person.name}
  setValue={(person: Person, value: string) => (person.name = value)}>
  {({ value, error, onChange }) => <div>Some children here...</div>}
</Field>

type FieldProps<T, S> = {
  name: string;
  getValue: (formValue: S) => T;
  setValue: (formValue: S, fieldValue: T) => void;
  formatter?: Formatter<T>;
  validators?: Array<Validator<T, S>>;
  updatingKey?: string | number;
  enableOnChangeValidation?: boolean;
  onValidate?: (options: OnValidateFieldOptions<T>) => void;
  children: (options: FieldChildrenOptions<T>) => React.ReactElement;
};

FieldProps

prop required description
name Label for correctly adding an error message to the error object. It should be unique within the form.
getValue Value access function inside formValue.
setValue Function to set a new value.
formatter Function that formats value.
validators Array of validators that will participate in the validation process of this component.
updatingKey By default, the rendering of a child component in a Field is memoized for performance reasons. You can add this key to let the component know when you still want to update it.
enableOnChangeValidation Enables validation on the onChange event.
onValidate Fires every time a field is validated.
children Render function that takes options. (FieldChildrenOptions).

type OnValidateFieldOptions<T, N extends HTMLElement> = {
  nodeRef: React.RefObject<N> | null;
  isValid: boolean;
  fieldValue: T;
};

type FieldChildrenOptions<T> = {
  name: string;
  value: T;
  error: string | null;
  nodeRef: React.RefObject<any>;
  validate: () => Promise<boolean>;
  notify: (value: T) => void;
  onChange: (value: T) => void;
};

FieldChildrenOptions

prop required description
name Label that was passed to the Field.
value Field value. Must be passed to the component that will trigger the value update, such as an input.
error optional Text error if field validation fails.
nodeRef You can pass a nodeRef to your input if you want to implement something like scrolling to an element that didn’t pass validation. This ref will later be passed to the onValidate callback for this Field.
validate Allows you to call the validation of this field, for example, on the onBlur event.
notify Allows you to manually cause a new value to be set in a field. For example, inside the onBlur event to return an unformatted value to the form. Suppose the value of a field is ‘$1,000’ after formatting, even though the field is of type number. In this case, it is convenient to call the function of transforming a string into a number inside onBlur so that the correct data is sent to the server.
onChange Must be passed to the component that will trigger the value update, such as an input.

Validator

A validator is a simple object that contains three fields: a method, a message and an innerrupt. If you want to implement asynchronous validation, for example for a request to the server, then in the validation method, you must return a promise. You can also make validation dependent on other fields, due to the fact that the validation method accepts not only the value of one validated field, but also the value of the entire form. Interrupt

import { type Validator, type ValidatorMethodOptions } from 'react-cool-forms';

type Validator<T, S> = {
  method: (options: ValidatorMethodOptions<T, S>) => boolean | Promise<boolean>;
  interrupt: boolean; // Whether to abort validation if it fails on this validator
  message: string;
};

type ValidatorMethodOptions<T, S> = {
  fieldValue: T;
  formValue: S;
};

// Example of sync validator
const required = {
  method: ({ formValue, fieldValue }) => Boolean(fieldValue),
  message: `It's required field`,
};

// Example of async validator
const checkLogin = {
  method: ({ formValue, fieldValue }) => {
    return new Promise(resolve => {
      // Emulates request to server
      setTimeout(() => {
        resolve(true);
      }, 200);
    })
  },
  message: 'This login already exists',
};

Formatter

import { type Formatter, type FormatterOptions } from 'react-cool-forms';

A formatter is a function that formats value. Allows you to implement input masks or otherwise transform the output. It takes options with previous value, next value and node.

<Field
  name='nickname'
  getValue={x => x.nickname}
  setValue={(x, v) => (x.nickname = v)}
  formatter={formatUppercase}>
  {({ value, nodeRef, onChange }) => (
    // Pass a nodeRef if you need a node reference in the format function to move the caret
    <TextField
      ref={nodeRef}
      label='Your nickname'
      value={value}
      onChange={onChange}
    />
  )}
</Field>

const formatUppercase = (options: FormatterOptions<string, HTMLInputElement>) => {
  const { prevValue, nextValue, node } = options;

 return nextValue.toUpperCase();
};

type Formatter<T, N extends HTMLElement> = (options: FormatterOptions<T, N>) => T;

type FormatterOptions<T, N> = {
  prevValue: T;
  nextValue: T;
  node: N | null;
};

Repeater

In order to work with array-based forms, there is a Repeater component. It renders a list of nested Fields that, behind the scenes, work as a separate form with its own context, but in the end, thanks to the Repeater magic, arrays of forms can be treated as one. You can also nest Repeaters within each other if your data structure requires it. For example, you have an array of companies in your structure, and each of those companies has an array of accounts, and so on.

import { Repeater } from 'react-cool-forms';

const getCompanies = (form: MyForm): Array<Company> => form.companies;
const setCompanies = (form: MyForm, value: Array<Company>) => (form.companies = value);
const getKey = (value: Company) => value.ID;
const renderTrigger = ({ append }) => {
  return <button onClick={() => append(createCompany())}>Add company</button>;
};

const getCompanyName = (company: Company) => company.name;
const setCompanyName = (company: Company, value: string) => (company.name = value);

<Form initialFormValue={initialFormValue} onSubmit={handleSubmit}>
  {({ submit }) => {
    return (
      <Repeater
        name='companies'
        getValue={getCompanies}
        setValue={setCompanies}
        getKey={getKey}
        renderTrigger={renderTrigger}>
        {({ idx, key, shouldFocus, remove }) => {
          return (
            <>
              <Field
                name={`companies(${key}).name`} // name can be any unique value
                getValue={getCompanyName}
                setValue={setCompanyName}
                updatingKey={idx} // for correct removing
                validators={[required]}>
                {({ value, error, onChange }) => <div>Some children here...</div>}
              </Field>
              <button onClick={() => remove(idx)}>remove company</button>
            </>
          );
        }}
      </Repeater>
    );
  }}
</Form>

type RepeaterProps<T, S> = {
  name: string;
  connectedRef?: React.Ref<RepeaterRef<T>>;
  getValue: (formValue: S) => Array<T>;
  setValue: (formValue: S, fieldValue: Array<T>) => void;
  getKey: (formValue: T) => string | number;
  interrupt?: boolean;
  triggerPosition?: 'before' | 'after';
  renderTrigger?: (options: RenderTriggerOptions<T>) => React.ReactElement;
  children: (options: RepeaterChildrenOptions<T>) => React.ReactElement;
};

RepeaterProps

prop required description
name Label for correctly adding an error message to the error object. It should be unique within the form.
connectedRef Ref for imperative access to list modification methods (append, prepend, insert, swap, remove).
getValue Value access function inside formValue.
setValue Function to set a new value.
getKey Function to return the unique ID of an object. Needed so that React knows when it should unmount the node completely.
interrupt Indicates whether to stop validation on the first error or not.
triggerPosition Specifies where to render form control buttons: before or after the list.
renderTrigger Function that that takes options (RenderTriggerOptions) and should render buttons for adding elements to an array.
children Render function that takes options (RepeaterChildrenOptions).

type RenderTriggerOptions<T> = {
  inProcess: boolean;
  size: number;
  append: (item: T, shouldFocus?: boolean) => void;
  prepend: (item: T, shouldFocus?: boolean) => void;
  insert: (idx: number, item: T, shouldFocus?: boolean) => void;
  swap: (from: number, to: number) => void;
  remove: (idx: number | Array<number>) => void;
};

RenderTriggerOptions

prop description
inProcess Shows if we are in the process of validation.
size Number of elements in the current array.
append Allows you to add an element to the end of the list.
prepend Allows you to add an element to the beginning of the list.
insert Allows you to add an element at the specified index.
swap Allows swapping 2 list items.
remove Allows you to remove an element from the list at the specified index or array of indices.

Note that some list management methods take a shouldFocus parameter. If this parameter is true, then when rendering the list, you will need to pass it as an input to the autoFocus property.

type RepeaterChildrenOptions<T> = {
  key: string | number;
  idx: number;
  isFirst: boolean;
  isLast: boolean;
  isEven: boolean;
  isOdd: boolean;
  isSingle: boolean;
  size: number;
  shouldFocus: boolean;
  formValue: T;
  errors: Record<string, string> | null;
  inProcess: boolean;
  remove: (idx: number | Array<number>) => void;
};

Debugger

This is the component you need to view the formatted formValue and errors while using the form. To use it, you just need to put it inside a Form.

import { Debugger } from 'react-cool-forms';

<Form initialFormValue={{}} onSubmit={handleSubmit}>
  {({ formValue, errors, inProcess, submit, reset }) => 
    <>
      <div>Some children here...</div>
      <Debugger />
    </>
  }
</Form>

// Example of output
{
  "errors": {
    "name": "It is required field"
  },
  "formValue": {
    "name": "",
    "companies": [
      {
        "ID": 1,
        "name": "Company #1"
      },
      {
        "ID": 2,
        "name": "Company #2"
      }
    ]
  }
}

useFormState

A hook that you can use to access the form value and some of its useful methods while inside child components.

const {
  formValue,
  errors,
  inProcess,
  addValidator,
  removeValidator,
  modify,
  validate,
  submit,
  reset,
} = useFormState();

You can use this hook to detect changes in the entire form or in part of it by setting the detectChanges function. Now your component will only render when that piece of data in the form changes.

useFormState<MyForm, string>({
  detectChanges: x => x.phone,
});

type UseFormStateOptions<T, S> = {
  detectChanges: (formValue: T) => S;
};

Be sure to look at examples of using various APIs in the examples folder.

LICENSE

MIT © Alex Plex

GitHub

View Github