Skip to content

πŸ“ πŸ• 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!

License

Notifications You must be signed in to change notification settings

Mati365/under-control

Repository files navigation

Banner

under-control

Codacy Badge Codacy Badge NPM NPM Downloads NPM version

Are you losing sanity every time you need to make a form? Are you tired enough of all antipatterns and cursed React frameworks? Screw that! Treat all forms and inputs as a recursive composable controls! under-control is a lightweight alternative to libraries such as react-hook-form, formik, react-ts-form, which, unlike them, allows you to turn your components into controllable controls.

Object type check example

πŸ“– Docs

πŸš€ Quick start

πŸ“¦ Install

Edit React Typescript (forked)

npm bundle size (scoped)

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:

Edit advanced-validation

πŸ“ 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>
  );
};

Edit not-validated-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>
  );
};

Edit validated-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>
  );
};

Edit advanced-validation

✨ 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>
  );
};

Edit form-inputs-relations

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

About

πŸ“ πŸ• 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!

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published