minimal-form-data-hoc

A minimalistic HOC for typed form data in React

Usage no npm install needed!

<script type="module">
  import minimalFormDataHoc from 'https://cdn.skypack.dev/minimal-form-data-hoc';
</script>

README

minimal-form-data-hoc (aka. miniform)

This package exposes a minimalistic higher-order component for typed form data. The implementation consists of <50 lines of code, with a key focus on simplicity. You are encouraged to copy and modify the source directly if the component does not fit your exact needs.

Installation

The package is available in the npm registry. Install using your favorite package manager, e.g.

npm install minimal-form-data-hoc

Usage

The higher-order component is used by first defining a form scheme with the following structure:

import miniform from 'minimal-form-data-hoc';

const scheme = miniform.createFormScheme(() => ({
  username: {
    value: 'Initial value',
    rules: [
      ['Please fill required value', (value: string) => !!value],
      ['Input too short', (value: string) => value.length < 5],
    ],
  },
}));

The scheme is then passed on to the withFormData HOC and used to construct the initial state of the form data. Note that the createFormScheme method is simply a typed identity function to help define schemes with correct type. Each property in the scheme is mapped to an object with a value, an error (if any of the validation rules return true) and an onChange method used to update the state of the form data. The following shows a minimal example with a single text field:

const LoginForm = miniform.withFormData(scheme)(props => {
  return (
    <form>
      Username: <input value={props.username.value} onChange={props.username.onChange} />
      <span>{props.username.error}</span>
    </form>
  );
});

Dynamic initial values and typed props

Note that the withFormData higher-order component's first argument is in fact a function. This function's input argument is the wrapped component's props which can be used to provide dynamic initial values. Consider the following example:

interface IProps {
  user: {
    name: string
  }
}

const scheme = miniform.createFormScheme((props: IProps) => ({
  username: { value: props.user.name },
}));

const LoginForm = miniform.withFormData(scheme)(props => {
  ...
});

This allows us to use the component's props for initial values. Furthermore, by providing a type for the input argument, the props in the wrapped component is also correctly typed.

Examples


Text fields

Username:

Password:

View source

Text fields simply pass on the values from the form data.

const scheme = miniform.createFormScheme(() => ({
  username: { value: '' },
  password: { value: '' },
}));

const InputForm = miniform.withFormData(scheme)(props => {
  return (
    <form>
      Username:<br/><input value={props.username.value} onChange={props.username.onChange} /><br/>
      Password:<br/><input value={props.password.value} onChange={props.password.onChange} />
    </form>
  );
});

export default InputForm;

Standalone checkbox

:ballot_box_with_check: Checkbox

View source

A standalone checkbox uses a boolean scheme property value, which is passed on to the input element's checked property.

import withFormData from 'minimal-form-data-hoc';

const scheme = miniform.createFormScheme(() => ({
  consent: { value: false },
}));

const InputForm = miniform.withFormData(scheme)(props => {
  return (
    <form>
      <input type='checkbox' checked={props.consent.value} onChange={props.consent.onChange} /> Consent
    </form>
  );
});

export default InputForm;

Standalone radio button

:radio_button: Radio button

View source

A standalone radio button uses a boolean scheme property value, which is passed on to the input element's checked property.

import withFormData from 'minimal-form-data-hoc';

const scheme = miniform.createFormScheme(() => ({
  consent: { value: false },
}));

const InputForm = miniform.withFormData(scheme)(props => {
  return (
    <form>
      <input type='radio' checked={props.consent.value} onChange={props.consent.onChange} /> Consent
    </form>
  );
});

export default InputForm;

Multiple checkboxes

:ballot_box_with_check: Coffee
:ballot_box_with_check: Tea
:white_large_square: Water

View source

Multiple checkboxes are grouped by assigning the input elements the same name as the scheme property. The value of the scheme property is an array, while the value of the input elements are literals. These literals are added and removed from the array as the boxes are toggled.

import withFormData from 'minimal-form-data-hoc';

const scheme = miniform.createFormScheme(() => ({
  beverage: { value: [] },
}));

const InputForm = miniform.withFormData(scheme)(props => {
  return (
    <form>
      <input
        type='checkbox'
        name='beverage'
        value='coffee'
        checked={props.beverage.value.some((x: string) => x === 'coffee')}
        onChange={props.beverage.onChange}
      />
      Coffee
      <br />
      <input
        type='checkbox'
        name='beverage'
        value='tea'
        checked={props.beverage.value.some((x: string) => x === 'tea')}
        onChange={props.beverage.onChange}
      />
      Tea
      <br />
      <input
        type='checkbox'
        name='beverage'
        value='water'
        checked={props.beverage.value.some((x: string) => x === 'water')}
        onChange={props.beverage.onChange}
      />
      Water
    </form>
  );
});

export default InputForm;

Multiple radio buttons

:radio_button: Coffee
:radio_button: Tea
:radio_button: Water

View source

Multiple radio buttons are grouped by assigning the input elements the same name as the scheme property. The values of the input elements are assigned to the form data state as the buttons are toggled.

import withFormData from 'minimal-form-data-hoc';

const scheme = miniform.createFormScheme(() => ({
  beverage: { value: '' },
}));

const InputForm = miniform.withFormData(scheme)(props => {
  return (
    <form>
      <input
        type='radio'
        name='beverage'
        value='coffee'
        checked={props.beverage.value === 'coffee'}
        onChange={props.beverage.onChange}
      />
      Coffee
      <br />
      <input
        type='radio'
        name='beverage'
        value='tea'
        checked={props.beverage.value === 'tea'}
        onChange={props.beverage.onChange}
      />
      Tea
      <br />
      <input
        type='radio'
        name='beverage'
        value='water'
        checked={props.beverage.value === 'water'}
        onChange={props.beverage.onChange}
      />
      Water
    </form>
  );
});

export default InputForm;

Troubleshooting

  • Warning: A component is changing an uncontrolled input to be controlled.

    Ensure that all initial values (defined in the scheme) are not null or undefined.

  • Type '(string | ((value: string) => boolean))[]' is not assignable to type 'readonly [any, (value: any) => boolean]'.

    Try defining the scheme as const to distinguish array and tuple types.

  • Argument of type '{ ... }' is not assignable to parameter of type '(props: {}) => Readonly<Record<string, Readonly<FormSchemeItem>>>'.

    Note that the withFormData HOC's first argument is a function and not an object. The function's input argument is the props of the wrapped component.

  • Property '...' does not exist on type 'PropsWithChildren<Readonly<Record<..., Readonly<FormDataItem>>>>'.

    Try typing the input argument of the scheme. This input type is merged with the type of the form data.