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.
npm install @under-control/forms
- Allows you to turn any component into a control with
value
andonChange
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.
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:
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>
);
};
Validation by default can result sync or async result and can be run in these modes:
blur
- when user blurs any input. In this modebind.path
returns alsoonBlur
handler. You have to assign it to input otherwise this mode will not work properly.change
- when user changes any control (basically whengetValue()
changes)submit
- when user submits form
Each validator can result also single error or array of errors with optional paths to inputs.
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 can be provided. In example above global
validator validates all inputs at once. If you want to assign error to specific input you can:
- Return
error("Your error", null "path.to.control")
function call inall
validator. - User
path
validator and return plainerror("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>
);
};
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.
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()} />;
};
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]')} />;
};
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>
);
};
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]
})}
/>
);
};