A simple, fast, and opinionated form library for React & React Native focusing on UX

react-ux-form

A simple, fast, and opinionated form library for React & React Native focusing on UX.

Setup

$ npm install --save react-ux-form
# --- or ---
$ yarn add react-ux-form

Features

  • Subscription-based field updates (avoid re-render the whole form on each keystroke ?)
  • Validation strategies ✨
  • Field sanitization
  • Mounted-only fields validation
  • Advanced focus handling
  • Best-in-class TypeScript support
  • Sync and async field validation
  • Sync and async form submission

Motivation

Why another React form library ??

Because, as silly as it seems, we couldn't find any existing library which fits our existing needs:

  • We want validation strategies per field because we fell in love with them when we read the re-formality documentation (which is unfortunately only available for ReScript).
  • It should be able to handle huge forms without a single performance hiccup.
  • Validation should be simple, reusable, and testable (aka just functions).
  • It shouldn't even try to validate unmounted fields.
  • It should have built-in focus management (to improve the keyboard flow of our React Native forms).

Validation strategies ✨

The key of good UX is simple: validation should be executed in continue, feedback should be provided when it makes sense.

Quick example: A credit card field ?

Let's say we want to display a valid state icon (✔) when the input value is a valid credit card number but don't want to display an error until the user blurs the field (and lets the value in an invalid state).

Something like this:

How do we easily achieve such magic? With the onFirstSuccessOrFirstBlur strategy ?‍♂️

const {} = useForm({
  cardNumber: { initialValue: "", strategy: "onFirstSuccessOrFirstBlur" },
});

Of course, onFirstSuccessOrFirstBlur will not fit perfectly every use-case!

That's precisely why every field config could declare its own strategy:

Strategy When feedback will be available?
onFirstChange On first change (as the user types or update the value)
onFirstSuccess On first validation success
onFirstBlur On first field blur
onFirstSuccessOrFirstBlur On first validation success or first field blur (default)
onSubmit On form submit

Note that:

  • The strategies will only be activated after the field value update / the form submission.
  • Once the first feedback is given (the field is valid or should display an error message), the field switches to what we call "talkative" state. After that, feedback will be updated on each value change until this field or the form is reset.

API

⚠️ The API is described using TypeScript pseudocode.
These types are not exported by the library / are not even always valid.

useForm

useForm takes one argument (a map of your fields configs) and returns a set of helpers (functions, components, and values) to manage your form state.

import { useForm } from "react-ux-form";

const {
  formStatus,
  Field,
  FieldsListener,
  getFieldState,
  setFieldValue,
  focusField,
  resetField,
  validateField,
  listenFields,
  resetForm,
  submitForm,
} = useForm({
  // Keys are used as fields names
  fieldName: {
    initialValue: "",
    // Properties below are optional (those are the default values)
    strategy: "onFirstSuccessOrFirstBlur",
    debounceInterval: 0,
    equalityFn: (value1, value2) => Object.is(value1, value2),
    sanitize: (value) => value,
    validate: (value, { focusField, getFieldState }) => {},
  },
});

Field config

type fieldConfig = {
  // The initial field value. It could be anything (string, number, boolean…)
  initialValue: Value;

  // The chosen strategy. See "validation strategies" paragraph
  strategy: Strategy;

  // An amount of time (in ms) to wait before triggering validation
  debounceInterval: number;

  // When performing async validation, it might happen that the value has changed between the start and the end of its execution
  // That's why we compare the two values: to ensure that the feedback given to the user is correct
  equalityFn: (value1: Value, value2: Value) => boolean;

  // Will be run on value before validation and submission. Useful from trimming whitespaces
  sanitize: (value: Value) => Value;

  // Used to perform field validation. It could return an error message (or nothing)
  // It also handle async: simply return a Promise that resolves with an error message (or nothing)
  validate: (value: Value) => ErrorMessage | void | Promise<ErrorMessage | void>;
};

formStatus

type formStatus =
  | "untouched" // no field has been updated
  | "editing"
  | "submitting"
  | "submitted";

<Field />

A component that exposes everything you need locally as a children render prop.

<Field name="fieldName">
  {
    (props: {
      // A ref to pass to your element (only required for focus handling)
      ref: MutableRefObject;
      // The field value
      value: Value;
      // Is the field validating? (only happen on async operations)
      validating: boolean;
      // Is the field valid?
      valid: boolean;
      // The field is invalid: here its error message.
      error?: ErrorMessage;
      // The onBlur handler (required for onBlur and onFirstSuccessOrFirstBlur strategies)
      onBlur: () => void;
      // The onChange handler (required)
      onChange: (value: Value) => void;
      // Focus the next field (uses the field config declaration order in useForm)
      focusNextField: () => void;
    }) => /* … */
  }
</Field>

<FieldsListener />

A component that listens for fields states changes. It's useful when a part of your component needs to react to fields updates without triggering a full re-render.

<FieldsListener names={["firstName", "lastName"]}>
  {
    (states: Record<"firstName" | "lastName", {
      // The field value
      value: Value;
      // Is the field validating? (only happen on async operations)
      validating: boolean;
      // Is the field valid?
      valid: boolean;
      // The field is invalid: here its error message.
      error?: ErrorMessage;
    }>) => /* … */
  }
</FieldsListener>

getFieldState

By setting sanitize: true, you will enforce sanitization.

type getFieldState = (
  name: FieldName,
  options?: {
    sanitize?: boolean;
  },
) => {
  value: Value;
  validating: boolean;
  valid: boolean;
  error?: ErrorMessage;
};

setFieldValue

By setting validate: true, you will enforce validation. It has no effect if the field is already talkative.

type setFieldValue = (
  name: FieldName,
  value: Value,
  options?: {
    validate?: boolean;
  },
) => void;

focusField

Will only work if you forward the Field provided ref to your input.

type focusField = (name: FieldName) => void;

resetField

Value will be set to initialValue, and user feedback will be hidden (the field is not talkative anymore).

type resetField = (name: FieldName) => void;

validateField

Once you manually call validation, the field automatically switches to talkative state.

type validateField = (name: FieldName) => Promise<ErrorMessage | void>;

listenFields

A function that listen for fields states changes. Useful when you want to apply side effects on values change.

React.useEffect(() => {
  const removeListener = listenFields(
    ["firstName", "lastName"],
    (states: Record<"firstName" | "lastName", {
      // The field value
      value: Value;
      // Is the field validating? (only happen on async operations)
      validating: boolean;
      // Is the field valid?
      valid: boolean;
      // The field is invalid: here its error message.
      error?: ErrorMessage;
    }>) => /* … */
  );

  return () => {
    removeListener();
  }
}, []);

resetForm

Will reset all fields states and the formStatus.

type resetForm = () => void;

submitForm

Submit your form. Each callback could return a Promise to keep formStatus in submitting state.

type submitForm = (
  onSuccess: (values: Partial<Values>) => Promise<unknown> | void,
  onFailure?: (errors: Partial<ErrorMessages>) => Promise<unknown> | void,
  options?: {
    // by default, it will try to focus the first errored field (which is a good practice)
    avoidFocusOnError?: boolean;
  },
) => void;

combineValidators

As it's a very common case to use several validation functions per field, we export a combineValidators helper function that allows you to chain sync and async validation functions: it will run them sequentially until an error is returned.

import { combineValidators, useForm } from "react-ux-form";

const validateRequired = (value: string) => {
  if (!value) {
    return "required";
  }
};

const validateEmail = (email: string) => {
  if (!/.+@.+\..{2,}/.test(email)) {
    return "invalid email";
  }
};

const MyAwesomeForm = () => {
  const { Field, submitForm } = useForm({
    emailAddress: {
      initialValue: "",
      // will run each validation function until an error is returned
      validate: combineValidators(
        isEmailRequired && validateRequired, // validation checks could be applied conditionally
        validateEmail,
      ),
    },
  });

  // …
};

hasDefinedKeys

As some of your fields might be unmounted on submit, the submitForm method could not guarantee that every field value is defined and valid. We export hasDefinedKeys helper function that allows you to test if some object keys are defined.

import { hasDefinedKeys, useForm } from "react-ux-form";

const MyAwesomeForm = () => {
  const { Field, submitForm } = useForm({
    firstName: { initialValue: "" },
    lastName: { initialValue: "" },
  });

  const handleSubmit = () => {
    submitForm((values) => {
      if (hasDefinedKeys(values, ["firstName", "lastName"])) {
        // values.firstName and values.lastName are defined (the fields are mounted)
      }
    });
  };

  // …
};

Quickstart

import * as React from "react";
import { useForm } from "react-ux-form";

const MyAwesomeForm = () => {
  const { Field, submitForm } = useForm({
    firstName: {
      initialValue: "",
      strategy: "onFirstSuccessOrFirstBlur",
      sanitize: (value) => value.trim(), // we trim value before validation and submission
      validate: (value) => {
        if (value === "") {
          return "First name is required";
        }
      },
    },
  });

  return (
    <form
      onSubmit={({ preventDefault }) => {
        preventDefault();

        submitForm(
          (values) => console.log("values", values), // all fields are valid
          (errors) => console.log("errors", errors), // at least one field is invalid
        );
      }}
    >
      <Field name="firstName">
        {({ error, onBlur, onChange, valid, value }) => (
          <>
            <label htmlFor="firstName">First name</label>

            <input
              id="firstName"
              onBlur={onBlur}
              value={value}
              onChange={({ target }) => {
                onChange(target.value);
              }}
            />

            {valid && <span>Valid</span>}
            {error && <span>Invalid</span>}
          </>
        )}
      </Field>

      <button type="submit">Submit</button>
    </form>
  );
};

More examples

A full set of examples is available on the demo website or in the /website directory project. Just clone the repository, install its dependencies and start it!