react-simple-form-manager

Easy and simple way to manage your react form state and validations

Usage no npm install needed!

<script type="module">
  import reactSimpleFormManager from 'https://cdn.skypack.dev/react-simple-form-manager';
</script>

README

react-simple-form-manager

MIT license npm size npm version build test coverage

Table of contents


Motivation

There are a lot of npm packages connected to react forms out there, and most of the popular ones support everything a form might require. Great, but that makes said tools complicated to use, even though a lot of forms might be small and simple.

What's missing? Something simple to manage the state and trigger the validations for a form. That is it.

What this package aims to achieve: One hook to easily and quickly manage the form data and validations with type support.

What this package does not provide: Inputs, wrappers, or any other form of UI components.

Although this package can be used with plain javascript, types are included in the package and you will only get the full benefit of intellisense with typescript.


Getting started

Install the package

npm i react-simple-form-manager

Import the useFormManager and use it with your form component:

import React from "react";
import { useFormManager } from "react-simple-form-manager";
import { GenericTextInput, GenericAmountInput } from "./inputs";

interface SimpleFormState {
    firstName: string;
    lastName: string;
    age: number;
}

export const SimpleForm: React.FC<SimpleFormProps> = (props) => {
    const formManager = useFormManager<SimpleFormState>({ onSubmit: props.onSubmit });

    return (
        <form onSubmit={formManager.handleSubmit}>
            <GenericTextInput
                label={"First Name"}
                onValueChange={formManager.updaterAndValidatorForField("firstName")}
                value={formManager.formState.firstName}
            />
            <GenericTextInput
                label={"Last Name"}
                onValueChange={formManager.updaterAndValidatorForField("lastName")}
                value={formManager.formState.lastName}
            />
            <GenericAmountInput
                label={"Age"}
                onValueChange={formManager.updaterAndValidatorForField("age")}
                value={formManager.formState.age}
            />
            <button type={"submit"}>Submit</button>
        </form>
    );
};

API

Props

Name Type Default Description
initialState Partial<TFormState> {} Initial data for form state
validators FormValidators<TFormState> {} Object with callbacks used to validate each form field
showErrorsAfter ShowErrorsAfter "submit" Moment when to trigger the visibility of the validation errors
onSubmit (formState: TFormState) => void undefined Callback executed when the form is submitted with no errors
allowSubmitWhen AllowSubmitWhen "hasEditsAndNoErrors" Which flags are used to allow prevent executing the onSubmit callback

Output

Name Type Description
formState TFormState The current form state
hasEdits boolean If there was any change to the initialState
hasErrors boolean If there are any errors in the form state
visibleErrors VisibleErrors Object with the errors that should be visible
updaterAndValidatorForField (fieldName: string) => (fieldValue: TFormValue) => void; Returns the callback to update the field value
updaterForFieldToTriggerAllValidations (fieldName: string) => (fieldValue: TFormValue) => void; Returns the callback to update the field value and trigger the validations for all other fields too
allowErrorVisibilityForField (fieldName: string) => () => void; Returns a callback to allow the visibility of the error
updateAndValidateField (fieldName: string, fieldValue: TFormValue) => void; Callback to update the field value
updateFieldAndTriggerAllValidations (fieldName: string, fieldValue: TFormValue) => void; Callback to update the field value and trigger the validations for all other fields too
updateAndValidateState (formState: Partial<TFormState>) => void; Callback to update the whole state at once
setHasEdits (hasEdits: boolean) => void; Callback to manually set the hasEdits flag
allowErrorVisibility (fieldName: string, isVisible?: boolean) => void; Callback to trigger the visibility of the error (isVisible defaults to true)
resetErrorVisibility () => void Callback to set all errors visibility to false
resetFormWithNewState (state: Partial<TFormState>) => void Callback to reset all the form internal variables and set a new state
handleSubmit () => void; Callback to pass to the form onSubmit

Form state

To have full intellisense and type support, you should provide a form interface to the useFormManager<MyCustomFormInterface>().

Note: Each field may be of any type, but nesting is not supported. If a field is myCustomField: SomeComplexObject the callback to update myCustomField will override the whole object every time.

interface MyCustomFormInterface {
    firstName: string;
    lastName: string;
    age: number;
    address: SomeComplexAddress;
}

interface SomeComplexAddress {
    street: string;
    city: string;
}
...

const formManager = useFormManager<MyCustomFormInterface>({
    initialState: {
        firstName: "John",
        lastName: "Doe",
        age: 18,
        address: {
            street: "My street name",
            city: "My city name",
        },
    },
});

Form validators

type FormFieldHasErrors = (fieldValue: TFieldValue, formState?: TFormState) => boolean;

All validation callbacks are optional. Fields with no validation callback are always valid. The callback should return true if there are any errors.

You can validate each field independently:

export const requiredString = (fieldValue: string) => {
    return !fieldValue;
};

export const percentageValidator = (percentage: number) => {
    return !percentage || percentage < 0 || percentage > 100;
};

Or you can take other form fields into consideration:

export const hasErrorInPaidAmount = (paidAmount: number, formState: TFormState) => {
    return !paidAmount || paidAmount > formState.totalAmount;
};

Each validator has the name of the field it corresponds to:

interface MyCustomForm {
    firstName: string;
    lastName: string;
    age: number;
}

...

const formManager = useFormManager<MyCustomForm>({
    validators: {
        firstName: someValidator,
        lastName: someValidator,
        age: someOtherValidator,
    },
})

Show errors after

type ShowErrorsAfter = "customTouch" | "submit" | "always";

Fields are validated on each change, but you might want to show the errors only after the user tried to submit, or onBlur, or maybe when some other event happens.

  • always will always display the error message for every field.
  • submit will display the error messages for every field after the user tries to submit the form for the first time.
  • customTouch will display the error message for each field as soon as the allowErrorVisibility is executed, and for every field after the the user tries to submit the form for the first time.

Allow submit when

type AllowSubmitWhen = "hasEditsAndNoErrors" | "hasNoErrors" | "always";

Both the hasEdits and the hasErrors flags are always updated nonetheless. However, you might want to allow submitting the form with different combinations.

  • hasEditsAndNoErrors will validate if there are any edits to the initial state and if there are no errors in the form.
  • hasNoErrors will only validate if there are no errors in the form.
  • always will always allow submitting the form.

Visible errors

An object that has the field name and a boolean that represents if the error should be visible or not, with the signature Type VisibleErrors = Partial<Record<keyof TFormData, boolean>>.

For example, these are the VisibleErrors generated by the MyCustomForm type:

interface MyCustomForm {
    firstName: string;
    lastName: string;
    age: number;
}
interface VisibleErrors {
    firstName: boolean;
    lastName: boolean;
    age: boolean;
}

Type safety and intellisense

If a type is provided to the useFormManager<MyFormDataType>, this package allows IDEs to work the intellisense magic and offers type safety for every attribute and attribute value type.

A gif representing type safety and intellisense

Usage examples

Slightly less simple use case

interface SimpleFormState {
    firstName: string;
    lastName: string;
    age: number;
    loveForDogs: LoveForDogs;
    nationality: Nationality;
}

type LoveForDogs = "unconditional" | "dogs-are-fine" | "everyone-loves-dogs";

enum Nationality {
    Portuguese = "portuguese",
    Spanish = "spanish",
}

const initialFormState: Partial<SimpleFormState> = {
    firstName: "John",
    lastName: "Doe",
    nationality: Nationality.Portuguese,
    loveForDogs: "everyone-loves-dogs",
};

const nameErrorMessage = "Your must include a name and it must be less than 15 characters.";
const ageErrorMessage = "You must be at least 18 to continue.";

export const SlightlyLessSimpleForm: React.FC = () => {
    const handleSubmit = (formState: SimpleFormState) => {
        console.log("Saved form state: ", formState);
    };

    const formManager = useFormManager<SimpleFormState>({
        onSubmit: handleSubmit,
        initialState: initialFormState,
        validators: {
            firstName: (name: string) => requiredValidator(name) || maxLength(name),
            lastName: (name: string) => requiredValidator(name) || maxLength(name),
            age: (age: number) => age < 18,
        },
        showErrorsAfter: "customTouch",
    });

    return (
        <form onSubmit={formManager.handleSubmit}>
            <GenericTextInput
                label={"First Name"}
                value={formManager.formState.firstName}
                onValueChange={formManager.updaterAndValidatorForField("firstName")}
                onBlur={formManager.allowErrorVisibilityForField("firstName")}
                errorMessage={formManager.visibleErrors.firstName && nameErrorMessage}
            />
            <GenericTextInput
                label={"Last Name"}
                value={formManager.formState.lastName}
                onValueChange={formManager.updaterAndValidatorForField("lastName")}
                onBlur={formManager.allowErrorVisibilityForField("lastName")}
                errorMessage={formManager.visibleErrors.lastName && nameErrorMessage}
            />
            <GenericAmountInput
                label={"Age"}
                value={formManager.formState.age}
                onValueChange={formManager.updaterAndValidatorForField("age")}
                onBlur={formManager.allowErrorVisibilityForField("age")}
                errorMessage={formManager.visibleErrors.age && ageErrorMessage}
            />
            <LoveForDogsSelect
                label={"Love for dogs"}
                onValueChange={formManager.updaterAndValidatorForField("loveForDogs")}
                value={formManager.formState.loveForDogs}
            />
            <NationalitySelect
                label={"Choose your nationality"}
                onValueChange={formManager.updaterAndValidatorForField("nationality")}
                value={formManager.formState.nationality}
            />
            <button type={"submit"}>Submit</button>
        </form>
    );
};

Dealing with objects

If your form has to deal with an object, say an address, there are three easy solutions:

  1. Use a helper method and spread the object;
  2. Use a mapper to flatten and rebuild the data structure;
  3. Using a custom component that updates the whole object at once.

Helper method

interface SimpleFormState {
    someAttribute: string;
    address: {
        city: string;
        country: string;
    };
}

export const SimpleForm = () => {
    const formManager = useFormManager<SimpleFormState>({
        onSubmit: handleSubmit,
    });

    const handleUpdateCityAddress = (city: string) => {
        formManager.updateAndValidateField("address", {
            ...formManager.formState.address,
            city,
        });
    };

    const handleUpdateCountryAddress = (country: string) => {
        formManager.updateAndValidateField("address", {
            ...formManager.formState.address,
            country,
        });
    };

    return (
        <form onSubmit={formManager.handleSubmit}>
            <GenericTextInput
                label={"Country"}
                value={formManager.formState.address.country}
                onValueChange={handleUpdateCountryAddress}
            />
            <GenericTextInput
                label={"City"}
                value={formManager.formState.address.city}
                onValueChange={handleUpdateCityAddress}
            />
            <button type={"submit"}>Submit</button>
        </form>
    );
};

Mappers

interface DataModel {
    someAttribute: string;
    address: {
        city: string;
        country: string;
    };
}
interface SimpleFormState {
    someAttribute: string;
    addressCity: string;
    addressCountry: string;
}

const mapDataModelToFormState = (data: DataModel): SimpleFormState => {
    return {
        someAttribute: data.someAttribute,
        addressCity: data.address.city,
        addressCountry: data.address.country,
    };
};

const mapFormStateToDataModel = (formState: SimpleFormState): DataModel => {
    return {
        someAttribute: formState.someAttribute,
        address: {
            city: formState.addressCity,
            country: formState.addressCountry,
        },
    };
};

export const SimpleForm = () => {
    const formManager = useFormManager<SimpleFormState>({
        initialState: mapDataModelToFormState(initialState),
        onSubmit: (formState: SimpleFormState) => handleSubmit(mapFormStateToDataModel(formState)),
    });

    return (
        <form onSubmit={formManager.handleSubmit}>
            <GenericTextInput
                label={"Country"}
                value={formManager.formState.addressCountry}
                onValueChange={formManager.updaterAndValidatorForField("addressCountry")}
            />
            <GenericTextInput
                label={"City"}
                value={formManager.formState.addressCity}
                onValueChange={formManager.updaterAndValidatorForField("addressCity")}
            />
            <button type={"submit"}>Submit</button>
        </form>
    );
};

Custom component

interface SimpleFormState {
    someAttribute: string;
    address: {
        city: string;
        country: string;
    };
}

export const SimpleForm = () => {
    const formManager = useFormManager<SimpleFormState>({
        onSubmit: handleSubmit,
    });

    return (
        <form onSubmit={formManager.handleSubmit}>
            <GenericAddressComponent
                label={"Country"}
                value={formManager.formState.address}
                onValueChange={formManager.updaterAndValidatorForField("address")}
            />
            <button type={"submit"}>Submit</button>
        </form>
    );
};