The API roughly reflects the great Binder API of the Vaadin Framework, while strongly relying on MobX features.
It is written in TypeScript and has first class support for TypeScript code, but should also work in ES 5+ environments.
-
Built for React
-
Widget framework agnostic
-
Example implementation for reactstrap in the sample app
-
-
Supports various MobX versions
-
v1.x: supports MobX 6
-
v0.x: supports MobX 4 and 5
-
-
Chaining Concept for validations and conversions
-
Localization support for error messages
-
Async field validation and conversion
-
Promise based API
A Binder manages form state for a set of fields represented by "dumb" observable field instances implementing the FieldStore interface. It is configured via a fluent api like this:
import { DefaultBinder, TextField, EmailValidator } from 'mobx-binder'
import { MomentConverter } from 'mobx-binder-moment'
import { TranslateFunction } from 'react-mobx-i18n'
export class ProfileStore {
public salutation = new TextField('salutation') // (1)
public fullName = new TextField('fullName')
public dateOfBirth = new TextField('dateOfBirth')
public email = new TextField('email')
public phoneNumber = new TextField('phoneNumber')
public binder: DefaultBinder
constructor(private personStore: PersonStore,
t: TranslateFunction) {
this.binder = new DefaultBinder({ t }) // (2)
this.binder
.forStringField(this.salutation).isRequired().bind() (3)
.forStringField(this.fullName).isRequired().bind()
.forStringField(this.dateOfBirth).withConverter(new MomentConverter('DD.MM.YYYY')).bind()
.forStringField(this.email)
.isRequired()
.withAsyncValidator(
(value) => sleep(1000).then(() => EmailValidator.validate()(value)),
{ onBlur: true })
.onChange(() => {
console.info('Email changed')
})
.bind()
.forField(this.phoneNumber).bind()
binder.load({ // (4)
fullName: 'Max Mustermann',
email: '[email protected]',
dateOfBirth: moment('1995-06-11')
})
}
}
-
These fields store all state needed to render the corresponding field view. All the properties are observable or computed.
-
The Binder needs a translation function. We are using react-mobx-i18n which provides a compatible implementation.
-
Each field get’s registered with the Binder, configuring the validator and converter chain as neccessary.
-
Load initial values via simple object properties (see Loading and storing field values)
A simple example of how to render such fields can be found in the sample code.
If you decide for using mobx-binder, there are some flavors to choose from:
- Binder
-
The core Binder tries to be as independent as possible of validation and translation frameworks. If you already have some of those frameworks in use, you might want to use only the core components and integrate it.
- SimpleBinder
-
This binder does not care about localization. Use this you don’t need translations or your validation framework of choice already provides translated error messages. SimpleBinder is part of the mobx-binder-core module
- DefaultBinder
-
The DefaultBinder already makes assumptions about the translation library, which should support ES2015 templating style keys and named arguments, like i18n-harmony and react-mobx-i18n. For that scenario, it already provides a set of ready-to-use Validators and Converters. All this is part of the mobx-binder module.
To illustrate the power of the framework, most examples in this documentation are using the DefaultBinder.
Depending on your needs, you might want to install from the following packages:
Package name | Description |
---|---|
mobx-binder |
Contains the DefaultBinder. Also re-exports |
mobx-binder-core |
Core package containing Binder and SimpleBinder. |
mobx-binder-moment |
Converters and validators for DefaultBinder supporting Moment.js |
mobx-binder-dayjs |
Converters and validators for DefaultBinder supporting Day.js |
npm install --save mobx-binder mobx-binder-dayjs
yarn add mobx-binder mobx-binder-dayjs
In general, mobx-binder should work flawlessly with all kinds MobX configuration settings.
Please note that - depending on your actual use case - you will get warnings in the browser console when setting reactionRequiresObservable
to true
, as some computed properties and actions just might not need to access observable state. We recommend disabling that setting (which corressponds to the default) in productive environments.
The properties and functions provided for each field are typically used by the React frontend components to render their state and make updates.
They are described on the FieldStore interface.
The Binder API is typically used from inside MobX stores.
The properties and functions provided are described on the Binder class.
To instantiate a Binder, we need a BinderContext
containing a translation function. This is needed for conversion and validation error messages. The translation function has to conform to the API of react-mobx-i18n
.
import { DefaultBinder } from 'mobx-binder'
...
const binder = new DefaultBinder({ t: i18nStore.translate })
To add fields to the binder, we just use the fluent API to "bind" fields:
import { DefaultBinder, TextField } from 'mobx-binder'
...
public fullName = new TextField('fullName')
...
binder.forField(fullName).bind()
After a bind
or bind2
call, more fields can be added:
public fullName = new TextField('fullName')
public email = new TextField('email')
...
binder
.forField(fullName).bind()
.forField(email).bind()
The 'bind()` method binds the value of a form field to a property named like the field name:
public fullName = new TextField('fullName')
...
binder.forField(fullName).bind()
// loading from object
binder.load({ fullName: 'Max Mustermann' }) // => fullName.value === 'Max Mustermann'
// storing to object
const values = binder.store() // values === { fullName: 'Max Mustermann' }
// storing to existing object
const values = { foo: 'bar' }
binder.store(values) // => values == { foo: 'bar', fullName: 'Max Mustermann' }
The bind()
command is a shorthand for a call to bind2
, which just stores a (converted and validated) field value to a backing object using a property named like the field. But it’s also possible to bind using more complex read and write callbacks:
public fullName = new TextField('fullName')
...
binder.forField(fullName).bind2(
source => source.businessRelation.person.fullName,
(target, newValue) => target.businessRelation.person.fullName = newValue)
)
const account = {
businessRelation: {
person: { fullName: 'Max Mustermann' }
}
}
// loading account data into fields
binder.load(account) // => fullName.value === 'Max Mustermann'
// updating account data
binder.store(account) // => account.businessRelation.person.fullName === 'Max Mustermann'
When you load() data, all the field values get a new value, which is internally stored as "unchanged".
Only if the field value is changing via an updateValue()
operation, the changed
property on field level gets true.
In the FieldOptions, you can pass an additional customChangeDetectionValueProvider
function for custom equality checks, in case you want different values to be considered the same, like phone numbers that can start with '+' or '00' or irrespective of any spaces.
Please note, that this does not affect anything else than the changed
property of the field and must not throw errors.
It’s especially useful in case of validations that should only happen when a field value actually changed, like for uniqueness checks.
binder
.forStringField(phoneNumber, {
customChangeDetectionValueProvider:
(value: string) => value.replaceAll(' ', '').replace(/^00/, '+')
})
.bind()
You can get a backend object only filled with data that has been changed via the Binder.changedData
getter.
In combination with the Binders apply()
method it’s possible to find changes between two sets of data:
public fullName = new TextField('fullName')
public email = new TextField('email')
...
binder
.forStringField(fullName).bind()
.forStringField(email, {
customChangeDetectionValueProvider: (value) => value.trim()
}).bind()
// loading from object
binder.load({
fullName: 'Max Mustermann',
email: '[email protected]'
})
// applying new set of data as field changes
binder.apply({
fullName: 'Max Mustermann-Musterfrau',
email: '[email protected]'
})
// binder.changedData returns { fullName: 'Max Mustermann-Musterfrau' }
For every field, we can specify validations to be done:
binder.forField(fullName).isRequired().withValidator(EmailValidator.validate()).bind()
Validations are processed in order of method calls - so in this example, it is first checked if the required
validation fails, and if it does, no further validation will happen.
To see the list of already supported validations, take a look into the mobx-binder/src/validation/
folder. You can also easily define your own custom validator, as long as it implements the Validator
type.
The isRequired()
validation has the special side effect that the required
property is set on the field, so that the rendering component can highlight it.
Also, the isRequired()
validation can be active or inactive based on a condition that can be passed as an optional argument.
Only valid field values are written to an object via binder.store()
.
If validation incurs expensive calculations or a backend request, it’s possible to do it asynchronously:
binder
.forStringField(fullName)
.withAsyncValidator((value) => sleep(1000).then(() => EmailValidator.validate()(value)))
.bind()
In contrast to synchronous validation, the async validation expects to get back a Promise
of the validation result. As this is a more expensive validation, it does not happen on every change of the field value, but only on submission. If you want an additional check on blur, you can configure this like so:
.withAsyncValidator(myAsyncValidator, { onBlur: true })
Only field values where asynchronous validation has been successfully finished are written to an object via binder.store()
.
Sometimes, the validation of one field depends on the value of another field. Given the data used to evaluate the condition is observable, there is not much to do:
public salutation = new TextField('salutation') // (1)
public fullName = new TextField('fullName')
binder
.forStringField(salutation)
.bind()
.forField(fullName)
.withValidator(someValidatorDependingOnValueOf(salutation))
.bind()
In this example, changes to the value of the salutation
field will automatically trigger a re-evaluation of the validity of the fullName
.
Usually, to get aware of changes to field values, you might just want to observe them by yourself via the standard MobX mechanisms. But in some scenarios it might also be helpful to use the onChange() method.
public salutation = new TextField('salutation') // (1)
public fullName = new TextField('fullName')
binder
.forStringField(salutation)
.isRequired()
.withConverter(new MomentConverter('DD.MM.YYYY'))
.onChange(moment => console.log(moment.format()))
.bind()
onChange
events will only be fired if all validators specified before have been succeeding. It will pass a valid value of a type depending on the position of the onChange()
call in the chain.
As with validators, converters can also be added to the binding chain:
import { MomentConverter, MomentValidators } from 'mobx-binder-moment'
...
binder.forStringField(fullName)
.isRequired()
.withConverter(new MomentConverter('DD.MM.YYYY'))
.withValidator(Validators.dayInPast())
.bind()
Converters have to fullfill the following interface:
interface Converter<_ValidationResult, ViewType, ModelType> {
convertToModel(value: ViewType): ModelType
convertToPresentation(data: ModelType): ViewType
isEqual?(first: ModelType, second: ModelType): boolean
}
A conversion is only tried if previous validations succeeded. A converter may fail if the value is not convertible, which means that Converters also act as validators.
Validators that are added after a converter will act on the already converted value. The API of Binder makes use of TypeScript generics to make sure that a Validator can only be applied to a matching data type.
Converters are bidirectional - that means that on loading values into the form, they are converted back into a string representation. Also, when a to-model conversion has been successful, the resulting value is passed back to convertToPresentation
and the rendered field value is updated.
When a simple equality check via ===
for the converted ModelType is not sufficient, the converter also has to implement isEqual
. This is required for the changed
property and other internal optimizations.
Please not that by default empty string field values are not any more converted automatically to undefined
. Instead, one can use binder.forField().withStringOrUndefined()
or simply binder.forStringField()
to configure the same behaviour explicitly.
As for the async validation, there might be cases where a conversion is done remotely and needs to be asynchronous. One example could be to contact some external service that validates phone numbers and also brings these numbers into some common format.
binder
.forStringField(fullName)
.withAsyncConverter(verifyAndPrettifyPhoneNumberConverter)
.bind()
Asynchronous converters have to fullfill the following interface:
interface AsyncConverter<_ValidationResult, ViewType, ModelType> {
convertToModel(value: ViewType): Promise<ModelType>
convertToPresentation(data: ModelType): ViewType
isEqual?(first: ModelType, second: ModelType): boolean
}
The convertToModel
method then is expected to return the validation result or reject with a ValidationError
. As with the async validation, this does not happen on every change of the field value, but only on submission. If you want an additional check on blur, you can configure this like so:
.withAsyncValidator(myAsyncValidator, { onBlur: true })
When the conversion has been successful, the resulting value is passed back to convertToPresentation
and the rendered field value is updated.
Only field values where asynchronous conversion has been successfully finished are written to an object via binder.store()
.
If a field should be hidden as part of a value change of a different field, it may become necessary to remove that field from the Binder completely, especially if it’s value is currently invalid and would prevent a form submission:
binder.removeBinding(fullName)
This updates the global validation status based on the fields that are left.
If the submit button of a form is clicked, this may trigger a binder.submit()
call. Just like binder.store()
, it stores the form field values into an object, but it also waits for asynchronous validations to be finished and maintains submission state.
public handleSubmit() {
return this.binder.submit()
.then(() => /* success */)
.catch(() => /* validation error */)
}
The submit() methods maintains a binder.submitting
property, indicating that submission of the form is still in progress. To make use of it, asynchronous follow actions have to be specified as parameter, so that the binder can still indicate submission as long as the server request is still ongoing.
public handleSubmit() {
return this.binder.submit({}, results => this.sendResultsToServer(results))
.catch(() => /* validation or other submission error */)
}
If a field related validation error occurs, the err.message
is empty, es it may contain some "global" error message.
For rendering a form, best practice is to create form field wrapper components.
Please also see the example implementation which integrates with reactstrap.
The project is using lerna for multipackage repository support.