under-control

πŸ“ πŸ• Are you losing sanity every time you need to make a form? Are you have enough of all antipatterns and cursed frameworks in React? Screw that! Treat all forms and inputs as a recursive composable control!

MIT License

Downloads
291
Stars
13
Committers
3

πŸ“– Docs

πŸš€ Quick start

πŸ“¦ Install

npm install @under-control/forms

✨ Features

  • Allows you to turn any component into a control with value and onChange properties. Treat your custom select-box the same as it would be plain <select /> tag! Other libs such as react-hook-form do not provide similar mechanism.
  • Better encapsulation of data. Due to low context usage it allows you to reuse built controllable controls in other forms.
  • Small size, it is around 4x smaller than react-hook-form and weights ~2.6kb (gzip).
  • Performance. Automatic caching of callbacks that binds controls. Modification of control A is not triggering rerender on control B.
  • Built in mind to be type-safe. Provides type-safe validation and controls binding.
  • Provides rerender-free control value side effects. Modify of control can reset value of form without doing additional useEffect.
  • Exports additional hooks such as use-promise-callback / use-update-effect that can be reused in your project.
  • Highly tested codebase with 100% coverage.

πŸ—οΈ Composition

πŸ–ŠοΈ Basic Custom Control

Build and treat your forms as composable set of controlled controls. Do not mess with implementing value / onChange logic each time when you create standalone controls.

Example:

import { controlled } from '@under-control/forms';

type PrefixValue = {
  prefix: string;
  name: string;
};

const PrefixedInput = controlled<PrefixValue>(({ control: { bind } }) => (
  <>
    <input type="text" {...bind.path('prefix')} />
    <input type="text" {...bind.path('name')} />
  </>
));

Usage in bigger component:

import { controlled } from '@under-control/forms';
import { PrefixedInput } from './prefixed-input';

type PrefixPair = {
  a: PrefixValue;
  b: PrefixValue;
};

const PrefixedInputGroup = controlled<PrefixPair>(({ control: { bind } }) => (
  <>
    <PrefixedInput {...bind.path('a')} />
    <PrefixedInput {...bind.path('b')} />
  </>
));

onChange output from PrefixedInput component:

{
  a: { prefix, name },
  b: { prefix, name }
}

These newly created inputs can be later used in forms. Such like in this example:

import { useForm, error, flattenMessagesList } from '@under-control/forms';

const Form = () => {
  const { bind, handleSubmitEvent, isDirty, validator } = useForm({
    defaultValue: {
      a: { prefix: '', name: '' },
      b: { prefix: '', name: '' },
    },
    onSubmit: async data => {
      console.info('Submit!', data);
    },
  });

  return (
    <form onSubmit={handleSubmitEvent}>
      <PrefixedInputGroup {...bind.path('a')} />
      <PrefixedInputGroup {...bind.path('b')} />
      <input type="submit" value="Submit" disabled={!isDirty} />
    </form>
  );
};

You can use created in such way controls also in uncontrolled mode. In that mode defaultValue is required.

<PrefixedInputGroup defaultValue={{ prefix: 'abc', name: 'def' }} />

Check out example of custom controls with validation from other example:

πŸ“ Forms

⚠️ Forms without validation

The simplest possible form, without added validation:

import { useForm } from '@under-control/forms';

const Form = () => {
  const { bind, handleSubmitEvent, isDirty } = useForm({
    defaultValue: {
      a: '',
      b: '',
    },
    onSubmit: async data => {
      console.info('Submit!', data);
    },
  });

  return (
    <form onSubmit={handleSubmitEvent}>
      <input type="text" {...bind.path('a')} />
      <input type="text" {...bind.path('b')} />
      <input type="submit" value="Submit" disabled={!isDirty} />
    </form>
  );
};

βœ… Forms with validation

Validation by default can result sync or async result and can be run in these modes:

  1. blur - when user blurs any input. In this mode bind.path returns also onBlur handler. You have to assign it to input otherwise this mode will not work properly.
  2. change - when user changes any control (basically when getValue() changes)
  3. submit - when user submits form

Each validator can result also single error or array of errors with optional paths to inputs.

Single validator

Example of form that performs validation on blur or submit event.

import { useForm, error, flattenMessagesList } from '@under-control/forms';

const Form = () => {
  const { bind, handleSubmitEvent, isDirty, validator } = useForm({
    defaultValue: {
      a: '',
      b: '',
    },
    validation: {
      mode: ['blur', 'submit'],
      validators: ({ global }) =>
        global(({ value: { a, b } }) => {
          if (!a || !b) {
            return error('Fill all required fields!');
          }
        }),
    },
    onSubmit: async data => {
      console.info('Submit!', data);
    },
  });

  return (
    <form onSubmit={handleSubmitEvent}>
      <input type="text" {...bind.path('a')} />
      <input type="text" {...bind.path('b')} />
      <input type="submit" value="Submit" disabled={!isDirty} />
      <div>{flattenMessagesList(validator.errors.all).join(',')}</div>
    </form>
  );
};

Multiple validators

Multiple validators can be provided. In example above global validator validates all inputs at once. If you want to assign error to specific input you can:

  1. Return error("Your error", null "path.to.control") function call in all validator.
  2. User path validator and return plain error("Your error").

Example:

const Form = () => {
  const {
    bind,
    handleSubmitEvent,
    submitState,
    validator: { errors },
  } = useForm({
    validation: {
      mode: ['blur', 'submit'],
      validators: ({ path, global }) => [
        global(({ value: { a, b } }) => {
          if (!a || !b) {
            return error('Fill all required fields!');
          }

          if (b === 'World') {
            return error('It cannot be a world!', null, 'b');
          }
        }),
        path('a.c', ({ value }) => {
          if (value === 'Hello') {
            return error('It should not be hello!');
          }
        }),
      ],
    },
    defaultValue: {
      a: {
        c: '',
      },
      b: '',
    },
    onSubmit: () => {
      console.info('Submit!');
    },
  });

  return (
    <form onSubmit={handleSubmitEvent}>
      <FormInput {...bind.path('a.c')} {...errors.extract('a.c')} />
      <FormInput {...bind.path('b')} {...errors.extract('b')} />

      <input type="submit" value="Submit" />

      {submitState.loading && <div>Submitting...</div>}
      <div>{flattenMessagesList(errors.global().errors)}</div>
    </form>
  );
};

✨ Binding controls

useControl is a core hook that is included into useForm and identical bind functions are exported there too. It allows you to bind values to input and it can be used alone without any form.

Bind whole state to input

In example below it's binding whole input text to string state with initial value Hello world.

import { useControl } from '@under-control/inputs';

const Component = () => {
  const { bind } = useControl({
    defaultValue: 'Hello world',
  });

  return <input type="text" {...bind.entire()} />;
};

Bind specific path to input

You can also bind specific nested path by providing path:

import { useControl } from '@under-control/inputs';

const Component = () => {
  const { bind } = useControl({
    defaultValue: {
      message: {
        nested: ['Hello world'],
      },
    },
  });

  return <input type="text" {...bind.path('message.nested[0]')} />;
};

Defining relations between inputs

When user modifies a input then b input is also modified with a value + ! character.

import { useForm } from '@under-control/forms';

const App = () => {
  const { bind } = useControl({
    defaultValue: {
      a: '',
      b: '',
    },
  });

  return (
    <div>
      <input
        type="text"
        {...bind.path('a', {
          relatedInputs: ({ newControlValue, newGlobalValue }) => ({
            ...newGlobalValue,
            b: `${newControlValue}!`,
          }),
        })}
      />
      <input type="text" {...bind.path('b')} />
    </div>
  );
};

Mapping bound value to input

It picks value from message.nested[0], appends ! character to it, and assigns as value to input:

import { useControl } from '@under-control/inputs';

const Component = () => {
  const { bind } = useControl({
    defaultValue: {
      message: {
        nested: ['Hello world'],
      },
    },
  });

  return (
    <input
      type="text"
      {...bind.path('message.nested[0]', {
        input: str => `${str}!`, // appends `!` value stored in message.nested[0]
      })}
    />
  );
};

License

MIT

Package Rankings
Top 13.06% on Npmjs.org
Badges
Extracted from project README
Codacy Badge Codacy Badge NPM Edit React Typescript (forked) Edit advanced-validation Edit not-validated-form Edit validated-form Edit advanced-validation Edit form-inputs-relations