Skip to content

nerdgeschoss/react-use-form-library

Repository files navigation

React Use Form Library

Tests

A simple form libray for React using hooks.

Motivation

The motivation for working on this solution for form handling comes from the fact that we wanted something very simple and straighforward to use, while also being as agnostic to the implementation as possible, to make it easy to integrate in every kind of project.

Contrary to other solutions that provide you with react components and/or wrappers, we went for a simple react hook that gives you all the tools needed to implement your forms as you see fit.

  • Easy to use: you supply a model and a submit function to the hook and you get back helpers to use in your inputs.
  • Small: the codebase is quite simple and small. Currently only under 7kb gzipped.
  • Declarative: we use the same handlers as the native inputs { value, onChange, onBlur }.

Table of Contents

  1. Installation
  2. Basic Example
  3. Optional fields
  4. Validation
  5. Error Handling
  6. Advanced Example
  7. Nested Objects
  8. Array values (FieldSet)
  9. API
  10. Development

Installation

Add the library to your project:

yarn add @nerdgeschoss/react-use-form-library

or

npm install @nerdgeschoss/react-use-form-library

Basic example

Codepen

To initialize the hook you will need to supply a valid model object and a custom onSubmit function.

The three main fields you get from the hook are model, fields and onSubmit.

  • model is the updated object which will contain the modified fields.
  • fields is an object in which each key will be generated from properties of the original model you supplied to the hook.
  • onSubmit is the handler you can provide to the <form> element. It will call preventDefault internally and execute the function provided to the hook as onSubmit
import { useForm } from '@nerdgeschoss/react-use-form-library';

function App(): JSX.Element {
  const { fields, onSubmit } = useForm({
    model: {
      name: '',
    },
    onSubmit: async ({ model }) => {
      console.log(model);
    },
  });

  return (
    <form onSubmit={onSubmit}>
      <input
        value={fields.name.value}
        onChange={(v) => fields.name.onChange(v.target.value)}
      />
      <button>Submit</button>
    </form>
  );
}

 


 

Optional Fields

You don't need to explicitly enumerate all properties in your model, the library will generate all necesary fields on demand by using Proxy.

import { useForm } from '@nerdgeschoss/react-use-form-library';

function App(): JSX.Element {
  const { fields, onSubmit } = useForm({
    // You can supply an empty object
    model: {},
    onSubmit: async ({ model }) => {
      console.log(model);
    },
  });

  return (
    <form onSubmit={onSubmit}>
      <input
        value={fields.name.value}
        onChange={(v) => fields.name.onChange(v.target.value)}
      />
      <input
        value={fields.phone.value}
        onChange={(v) => fields.phone.onChange(v.target.value)}
      />
      <button>Submit</button>
    </form>
  );
}

If you use Typescript you will have to supply the interface for your model

// ...
const { model, fields, onSubmit } = useForm<{ name?: string; phone?: number }>({
  model: {},
  onSubmit: async () => {
    console.log(model);
  },
});
// ...

 


 

Validation

Codepen

The validations object needs to correspond your model object. Each key can take one of the following values:

  • A string that contains prebuild common validations. Currently supported are: required, email, number and more.
  • A custom validation function with the signature (value: model): string[] => {}. The value parameter is useful to supply the updated model and make comparisons. For this validation to report an error you have to return an array of strings. Each string is supposed to be an error that you can later display in the UI.
  • An array which can contain the previous both.
const { model, fields, onSubmit, valid } = useForm({
  model: {
    name: '',
    phone: '',
    customRegex: '',
    email: '',
    emailConfirmation: '' ,
  },
  validations: {
    // A single string
    name: 'required',
    // A function
    phone: (model) => {
      if (isNaN(model.phone)) {
        return ['not a number'];
      }
    },
    // A Regex
    customRegex: /[a-g]/,
    // A combination
    email: ['required', 'email']
    emailComfirmation: [
      'required',
      'email',
      (model) => {
        if (model.email !== model.emailConfirmation) {
          return ['Email must be the same']
        }
      }
    ]
  },
});

 

Predefined validation strings

Property Details
required checks if the field is not empty.
email checks if the value is a valid email address.
json checks if the value is a valid json object.
website checks if the value is a valid website (http / https).
number checks if the value is a number.

 


 

Submit Error Handling

Codepen

The hook also exposes another method: onSubmitError, which is handy if you don't want to use a try/catch in your onSubmit function.

const { model, fields, onSubmit, valid } = useForm({
  model: {
    name: '',
    phone: '',
  },
  onSubmit: async () => {
    throw new Error('submit error');
  },
  onSubmitError: (error) => {
    alert('error');
  },
});

 


 

Form state

Codepen

There are several properties exposed from the hook to deal with the state throughout the form lifetime

Property Details
dirty A boolean value indicating that one or more fields have been changed
valid A boolean indicating that the form is valid or not, this one depends on the validation rules provided.
error any error thrown within the onSubmit function is stored here as an error object
submissionStatus Displays the current status of the submission process

 

Submission Status

This variable contains the current state of the form submission process. At any time the form will be in one of the following states:

Status Details
idle The form has not yet been submitted. It also applied when the form is reset
submitting The form is being submitted. This works like a loading state.
submitted The form has been successfully submitted.
error There is an error while submitting.

 

Reset

Sometimes it is useful to reset the form programatically. For this there are two helpful methods:

Property Details
reset Clears all fields.
resetError Removes the current error.

 


 

Fields

Codepen

Every key supplied to the model property of the hook will be parsed into a field

// ...
const { fields } = useForm({
  model: {
    name: '',
  },
});
// ...

A field will be an object that you can use in a simple setup like:

// ...
<input
  // The updated value
  value={fields.phone.value}
  // A function to modify the value
  onChange={(v) => fields.phone.onChange(v.target.value)}
/>
// ...

Or a more complex solution:

// ...
<div>
  // The required property is derived from the validation of the field
  <label>Name {fields.name.required ? '(required)' : ''}: </label>
  <input
    value={fields.name.value}
    onChange={(v) => fields.name.onChange(v.target.value)}
  />
  // The errors property is an array of strings with all errors for the field
  {fields.name.errors.length && (
    <div>
      {fields.errors.forEach((error) => {
        return <div>{error}</div>;
      })}
    </div>
  )}
</div>
// ...

 

Helpful properties

touched

If the field is required, the required-field error will always be present if the value is empty. It is good UX to only display the required error if the field has been touched, for this you can use the touched property which will be true once the field has lost focus for the first time (onBlur event).

Important !!

If you want to immediately display errors based on the touched property, you need to add the onBlur event to your input. Otherwise it will only be displayed after an update event triggered by onChange.

Submitting the form will also "touch" all fields, to make sure errors are displayed if the form is invalid.

// ...
const displayErrors = fields.name.touched && fields.name.errors.length;

<div>
  <input
    value={fields.name.value}
    onChange={(v) => fields.name.onChange(v.target.value)}
    onBlur={fields.name.onBlur}
  />
  {
    displayErrors && (
      <div>
        {fields.errors.forEach((error) => {
          return <div>{error}</div>;
        })}
      </div>
    );
  }
</div>
// ...

 

valid

A simple getter that returns true if there aren't any errors. This would simplify the above conditional to:

const displayErrors = fields.name.touched && !fields.name.valid;

It is also useful if you want to give your input a conditional class to show the user if the field is valid or not

<div className={fields.phone.valid ? 'input--valid' : ''}>
  <input
    value={fields.phone.value}
    onChange={(v) => fields.phone.onChange(v.target.value)}
  />
</div>

 

dirty

When instantiated, a field will store it's original value in a variable. This getter will then compare the original value to the current value and return true if they are different.

 


 

Advanced example

import { useForm } from '@nerdgeschoss/react-use-form-library';

export function MyForm({ isNewItem, addItem, updateItem }: Props): JSX.Element => {
  const { fields, onSubmit, submissionStatus } = useForm({
    model: {
      name: '',
      age: 25,
    },
    onSubmit: async ({model, changes, reset}) => {
      if (isNewItem) {
        // You can use here the updated model, which includes the original model and any changes made
        await addItem(model);
      } else {
        // For updating, you can use only the changes that were made
        await updateItem(changes);
      }
      // If you need to clear the fields, you can call on reset form
      reset();
    },
    // Handle any errors
    onSubmitError: (error) => {
      alert('error');
    },
  });

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input
          value={fields.name.value}
          onChange={(v) => fields.name.onChange(v.target.value)}
        />
        <input
          value={fields.age.value}
          // Make sure you parse the value to be consistent with the model
          onChange={(v) => fields.age.onChange(Number(v.target.value))}
        />
        // You can use submitting to display loading state
        {submissionStatus === 'submitting' ? (
          <div>Loading...</div>
        ) : (
          // Valid state can also be used to disable submit
          <button disabled={!valid || !dirty}>Submit</button>
        )}
      </form>
    </div>
  );
}

 


 

Nested Objects

Each FormField object is also able to contain fields. It behaves similarly to the Form object but with slight differences in its methods (See API).

import { useForm } from '@nerdgeschoss/react-use-form-library';

function App(): JSX.Element {
  const { fields, onSubmit } = useForm({
    model: {
      address: {
        streetName: '',
      },
    },
    onSubmit: async ({ model }) => {
      console.log(model);
    },
  });

  return (
    <form onSubmit={onSubmit}>
      <input
        value={fields.address.fields.streetName.value}
        onChange={(v) =>
          fields.address.fields.streetName.onChange(v.target.value)
        }
      />
      <button>Submit</button>
    </form>
  );
}

Validations also work with nested objects

import { useForm } from '@nerdgeschoss/react-use-form-library';

function App(): JSX.Element {
  const { fields, onSubmit } = useForm({
    model: {
      address: {
        streetName: '',
      },
    },
    onSubmit: async ({ model }) => {
      console.log(model);
    },
    validations: {
      address: {
        streetName: 'required',
      },
    }
  });

  // ...

Since the fields property within FormField is also based on a Proxy object, you can access nested properties without explicitely declaring them

function App(): JSX.Element {
  /* If you use Typescript you have to define the model interface, otherwise you'll get an error
  while trying to access the nested properties */
  const { fields, onSubmit } = useForm<{
    bankDetails?: {
      address?: {
        screetName: string;
      };
    };
  }>({
    model: {},
    onSubmit: async ({ model }) => {
      console.log(model);
    },
  });

  return (
    <form onSubmit={onSubmit}>
      <input
        value={fields.bankDetails.fields.address.fields.streetName.value}
        onChange={(v) =>
          fields.bankDetails.fields.address.fields.streetName.onChange(
            v.target.value
          )
        }
      />
      <button>Submit</button>
    </form>
  );
}

 


 

FieldSet

A FieldSet is basically an array of FormFields. Contrary to nested objects, a FieldSet will only be instantiated if it is explicitly declared in the model.

Important !!

If you don't initialize the property in the model and try to access it in your code you will get an error.

This is because at the moment of field instantiation, the addField method within a Form (or a FormField if it is a nested object) will ask if the model value is actually an Array. It is not possible to implicitly determine if the field is array-based only from the type definition.

const { fields } = useForm<{ emails: string[]; images: string[] }>({
  model: {
    images: [],
  },
});

return (
  <form onSubmit={onSubmit}>
    /* This will result in an error because emails has been instantiated as a
    FormField not as a FieldSet */
    {fields.emails.elements.map((field) => {
      // ...
    })}
    /* This will work as intented */
    {fields.images.elements.map((field) => {
      // ...
    })}
  </form>
);

The FieldSet object has a elements property, which is an array of FormField (more in the API)

function App(): JSX.Element {
  const { fields, onSubmit } = useForm({
    model: {
      emails: ['[email protected]'],
    },
    onSubmit: async ({ model }) => {
      console.log(model);
    },
  });

  return (
    <form onSubmit={onSubmit}>
      // a FieldSet fields property is iterable
      {fields.emails.fields.map((field) => {
        // Every FieldSet field is a FormField
        return (
          <div className="input">
            <input
              value={field.value}
              onChange={(v) => field.onChange(v.target.value)}
            />
            // FormFields regenerated from a FieldSet also have a remove method
            <button onClick={() => field.remove()}>remove</button>
          </div>
        );
      })}
      // FieldSet also has an insert method to create new items
      <button onClick={() => fields.emails.add('')}>Add Field</button>
      <button>Submit</button>
    </form>
  );
}

 

Removing Items

FormField objects inside a FieldSet include a remove method to remove the corresponding element..

<button onClick={() => fields.emails.fields[0].remove()}>remove</button>

 

Validating a FieldSet

A FieldSet takes the same kind of validation as any other field. On instantiation it will be saved in memory and it will be further applied to any new field created.

useForm({
  model: {
    // Initialize the field
    emails: [],
  },
  validations: {
    // Will be applied to any item added to emails
    emails: ['required', 'email'],
  },
});

If the validation is of the type required, it will also make the field invalid unless it has at least one element.

useForm({
  model: {
    // emails.valid will be false because we're initializing it with no elements.
    emails: [],
  },
  validations: {
    emails: 'required',
  },
});

 

Advanced FieldSet

A common escenario would be to have an array of objects in your model.

const { fields, onSubmit } = useForm<{
  images: Array<{
    id: string;
    url?: string;
  }>;
}>({
  model: {
    // Initialize the field
    images: [],
  },
});

return (
  <form onSubmit={onSubmit}>
    {fields.images.fields.map((field) => {
      return (
        <div className="input">
          /* You can access a property whithin the field, as you would with a
          nested object */
          <input
            value={field.fields.url.value}
            onChange={(v) => field.fields.url.onChange(v.target.value)}
          />
          <button onClick={() => field.remove()}>remove</button>
        </div>
      );
    })}
    <button
      onClick={() =>
        fields.images.insert({
          id: generateId(),
        })
      }
    >
      Add Field
    </button>
    <button>Submit</button>
  </form>
);

 


 

API

useForm

Props

Property Details
model Your form model, it should be an object (can be empty). Every property will be mapped into a field.
validations A validations object.
onSubmit Your custom submit function. It will be parsed internally and provide a onSubmit handler to call programaticaly.
onSubmitError A useful handler to deal with errors.
onChange A handler for changes in all form fields.

 

Form

Property Details
changes A plain object that contains only the modified fields
model An updated object that contains the updated model
fields A mapped collection, which has a FormField for every key in the model
dirty a getter checking if there are any changes
valid a getter checking if all required fields are valid
onSubmit a method that triggers the function passed as onSubmit param.
submitting a loading state for the onSubmit method
reset this helper method will reset every field to it's original value

 

Form Field

Property Details
value the updated value of the current field
required a required field useful to determine a valid state for the form
errors a string array containing any errors
validation a validation object that is triggered on every update
focused a boolean stating if the field is currently in focus, you'll need to use onFocus/onBlur to change this state
onChange a value change method, sets touched to true and triggers an update
onBlur this method sets the touched state to true and focused to false
onFocus sets focused to true
hasValue a helper method to determine empty state. Checks for empty strings
validate a validation function triggered on every update
setTouched takes a boolean and will set the touched state to this value
reset a helper method that resets the field to it's original value
valid a getter that checks if the field has any errors
dirty a getter that compares the current value to the original value passed on instantiation
touched a touched state, initially false and changed to true when calling onChange/onBlur
remove Only within a FieldSet, it removes the object from the collection

 

Nested Fields

These following methods will behave differently if the FormField has nested fields

Property Details
onChange It takes an object and will create/update every field from the keys of this object
hasValue It is true when all fields have value
validate It will run validations for all nested fields
setTouched Takes a booleand and will set every nested field touched property to this value
reset It resets the value of every nested field
touched Will be touched when every fields is touched
valid Will be valid when every fields is touched
dirty Will be valid when at least one field is dirty
fields A mapped collection, which has a FormField for every key in the value

 

FieldSet

Property Details
fields An array of FormField objects
onChange Useful to update many fields at the same time. It takes an array value and will update every field based on the index. If the passed array is longer than the generated items, it will create more
setTouched It takes a boolean parameter and will set every item touched property to this value
reset It resets every field
validate It validates every field
insert It takes a comma separated array of arguments and adds a new FormField for each value
removeField It removes a field given an object reference value
value It returns an array with the value of every FormField item
dirty Will be true if any item is dirty
touched Will be true if every item is touched

 


 

Development

Install dependencies with yarn install. You can run interactive tests with yarn test and lint your project with yarn lint.

If you work with VSCode, it automatically switches to the correct TS version and formats on save via prettier. There is also an included launch config, so you can run and debug tests.

The VSCode Jest Extension is highly recommended as it gives you inline test results, code coverage and debugging right within VSCode (VSCode will automatically prompt you to install this extension).

Releases are done via GitHub Releases. Create a new release there, this will automatically trigger a workflow to publish the package.

 

Sandbox App

There is a also a sandbox basic application to play around with the library. Use yarn start to start up the parcel server, and you can find the files inside the /sandbox-app folder.

Troubleshoot

Sometimes you may get the following bug/error from parcel:

Conflicting babel versions found in .babelrc.

To solve this, after you started the dev server, go to the .babelrc file and comment these two lines:

{
  "presets": ["@babel/preset-env", "@babel/preset-typescript"],
  "plugins": [
    // "@babel/plugin-transform-runtime",
    // "babel-plugin-transform-class-properties"
  ]
}

After the server starts correctly you should uncomment those lines. If you still have some issues, try restarting the dev server.