Skip to content

metro-nom/mobx-binder

Repository files navigation

mobx-binder

This library provides a convenient way of handling form state in a React + MobX web app.

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.

Features
  • 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

Concept

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: 'max.mustermann@no-reply.com',
            dateOfBirth: moment('1995-06-11')
        })
    }
}
  1. These fields store all state needed to render the corresponding field view. All the properties are observable or computed.

  2. The Binder needs a translation function. We are using react-mobx-i18n which provides a compatible implementation.

  3. Each field get’s registered with the Binder, configuring the validator and converter chain as neccessary.

  4. 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.

Getting Started

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.

Packages

Depending on your needs, you might want to install from the following packages:

Package name Description

mobx-binder

Contains the DefaultBinder. Also re-exports mob-binder-core, so if you want to use the Default Binder, there is no need to additionally depend on it.

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

Sample install using NPM:
npm install --save mobx-binder mobx-binder-dayjs
Sample install using YARN
yarn add mobx-binder mobx-binder-dayjs

MobX Configuration

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.

API

FieldStore

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.

Binder

The Binder API is typically used from inside MobX stores.

The properties and functions provided are described on the Binder class.

Binder configuration

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()

Loading and storing field values

…​using 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' }

…​using bind2()

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'

Handling changes

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: 'max.mustermann@codecentric.de'
})

// applying new set of data as field changes
binder.apply({
    fullName: 'Max Mustermann-Musterfrau',
    email: 'max.mustermann@codecentric.de'
})

// binder.changedData returns { fullName: 'Max Mustermann-Musterfrau' }

Validation

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().

Asynchronous validation

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().

Conditional validation

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.

Change events

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.

Conversion

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.

Asynchronous conversion

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().

Conditional visibility

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.

Submission

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.

Rendering a form

For rendering a form, best practice is to create form field wrapper components.

Please also see the example implementation which integrates with reactstrap.

Development

The project is using lerna for multipackage repository support.

Initial setup

npm install
npm run bootstrap

Full Build

npm run build

Start sample application

cd packages/sample
npm start

Clean Release (without pull request)

First merge master into the branch and check that the branch is running fine
git merge master
npm run full-build
Merge the branch back into develop
git checkout master
git merge <branchName>
and perform the release
npm run version
npm run publish

TODOs

  • Add coverage to the pipeline

  • Create more re-usable validators

  • Create integration components vor various open source React component libraries (contributions are welcome ;-)