/bidi-mobx

Two-way binding is back, and this time it's respectable

Primary LanguageTypeScriptMIT LicenseMIT

bidi-mobx

Two-way binding is back, and this time it's respectable

Build Status Coverage Status

Declare your view model:

export default class Person {

    // A text field, validated to between 1-20 characters
    name = field(stringLimits(1, 20)).create("", "Name")

    // A number field, zero decimal places, between 0-120
    age = field(numberLimits(0, 120)).also(numberAsString(0)).create(0, "Age");

    // A field display "tags" using a custom format (array of strings)
    tags = field(tagsAsString).create([], "Tags");

    // The combined validation state
    rule = rules([this.name, this.age, this.tags]);
}

Bind your view to it:

function PersonEditor({ person }: { person: Person }) {

    return (
        <div>
            <div><label>Name <TextInput value={person.name} /></label></div>
            <div><label>Age <TextInput value={person.age} /></label></div>
            <div><label>Tags <TextInput value={person.tags} /></label></div>

            <RuleBullets rule={person.rule}/>
        </div>
    );
}

Use binding-ready simple form components:

  • <CheckBox>
  • <RadioButton>
  • <Select>
  • <TextInput>

Create your own two-way value adaptors:

const tagsAsString = {
    // Render takes a model value and returns a view value
    render(value: string[]) {
        return value.slice(0).sort().join(" ");
    },
    // Parse does the opposite (and is allowed to throw ValidationError)
    parse(str: string) { 
        return str.split(/\s+/).filter(s => s).sort();
    }
};

And then chain them together to create fields that automatically convert both directions, applying validations and converting between model and view representations.

Kitchen sink demo

Gradually growing... click here

Install

npm install bidi-mobx

You may need to define mobx as an external in your Webpack config to avoid it being duplicated

WAT?

There's a UI pattern with a stupid name: MVVM (Model/View/View-Model), but it's a very cool idea. The takeaway is that a view needs to be supported by state data that is additional to the pure data being edited. More discussion about this.

If you create a view-model that reflects the structure of your UI, you can then load your pure model data into it and "bind" to it with a standard vocabulary of visual components. They are linked in both directions. Being declarative is beneficial for describing the flow of information from model to view, and it turns out to be just as beneficial in the opposite direction too. It's especially easy if you can use the same declarations for both. For a lot of apps it's the most simple, easy to maintain, easy to get right and automatically performant approach.

React already provides a beautiful component-based and declarative UI description system. MobX provides automatic observing and computed (on a more sound basis than Knockout). So what else do we need?

A component representing a "control", an editor for a value, needs a way to bind to that control. In React this means creating the relationship twice: you set the value of the control and you also provide an onChange handler that updates the model.

For super slickness, we start with the boxm library, which provides a way to get a BoxedValue, a reference to a mutable property. This is a single nugget of goodness through which we can both get and set the value. We provide a version of the box function that is optimized for MobX.

To this we add a small set of core form components, which are extremely thin wrappers around standard DOM form elements, ready to use, with no added styling and no capability taken away:

  • <CheckBox> -> <input type="checkbox"> source
  • <RadioButton> -> <input type="radio"> source
  • <Select> -> <select> and <option> source
  • <TextInput> -> <input type="text"> source

All these require a value prop that is a BoxedValue. A bunch of noise disappears from your JSX and your UI becomes more readable and understandable.

Finally, we provide a simple way to declaratively transform observable values in both directions. This enables binding a TextInput to a number, or any kind of validation, with very short neat declarations. There's also a ridiculously simple component to display validation problems (so simple that you could easily cook up your own variant if you want a different structure).

Validation

The initial use case for this is using a <TextInput> for data that can be represented as a string but only a subset of all possible strings are valid: numbers being the classic example.

To support this, instead of declaring an observable property of type number:

@observable orderQuantity = 5

(which you'd then need to bind to with box), declare it like this:

orderQuantity = field(numberAsString()).create(5)

Or if you are "wrapping" an existing model object containing an orderQuantity number @observable:

orderQuantity = field(numberAsString()).use(box(myModel).orderQuantity)

(That is, box the model's orderQuantity number so you can use it as the underlying store for your field).

Now the resulting orderQuantity field is "pre-boxed". It has get/set methods for the string version of the value, so it can be directly passed to <TextInput> to bind it:

<label>Quantity to order: <TextInput value={orderQuantity}/></label>

It also has a sub-property (observable) called model that contains the number value which you can read/write freely (if you called use then this is equivalent to reading/writing the underlying model property).

In addition, it has an observable property called errors that contains an array of strings; if all is well, this array is empty. If the current text input is not valid, errors will contain one or more messages complaining to the user.

Whenever the text or the model value changes, everything updates automatically. It updates synchronously, like MobX itself, so call-stack debugging is manageable.

There is some built-in magic in <TextInput> so it recognises when an errors property is available and can add a class (by default has-errors) and append to the title to get a tooltip, so this may be enough by itself.

Anything that has an errors string array is a Rule. You can use <RuleBullets rule={myRule}> display all errors in a bullet list. Of course you may have multiple fields and want to display all their current errors in one place. You can create a combined rule with the rules function:

allRules = rules([orderQuantity, customerName, customerAddress])

This is still one rule, but it's errors property lists the concatenation of all the errors from the rules you pass to it.

Chaining adaptors

The initial object returned from field supports a very simple "fluent" API. The create method is the terminal call that actual creates the field object. But before that, you can call also to compose another "adaptor" (a conversion or a validation) around the existing one(s):

price = field(numberLimits(0, 1000)).also(numberAsString(2));

We start the field with numberLimits which means that our base (model) value will be a number, and we say it must be positive and no more than 1000. We then use also to further say that it should be wrapped in a string, and we specify it should display only 2 decimal places.

The argument to field and also is an adaptor:

export interface Adaptor<View, Model> {
    render(model: Model): View;
    parse(view: View): Model;
}

It specifies data transformation in two directions. The parse method is expected to throw a ValidationError exception if it doesn't like the value it receives.

You can cook up a pure validation adaptor (which doesn't change the value) with the checker function:

export function numberLimits(min: number, max: number) {
    return checker((val: number) => 
        (val < min) ? `Minimum value ${min}` :
        (val > max) ? `Maximum value ${max}` :
        undefined);
}

Asynchronicity

The approach taken here is in line with MobX's general approach: synchronous updating and no staleness. If asynchronous validation rules are required, this can be achieved quite easily by defining a rule based off the value of a computedAsync - see computed-async-mobx.

Prior Art

From Knockout.js comes the idea of including a minimal set of primitives for handling the DOM's form fields, but making them so simple that they also work as examples for user-written controls (e.g. integrating with your favourite date picker).

I looked properly at FormState 🌹 as I was thinking through the validation/conversion approach. Main similarities:

  • Representing each field's view state with an object containing model and view as separate values (equivalent of model is called $)
  • Validation rules can added to a field
  • Fields can be aggregated into one object to check validation of all

Main differences:

  • Model and view state are the same type (validation only, no conversion or adaptor chaining)
  • Has separate value and onChange features instead of a single BoxedValue
  • Validation is an explicitly requested command, not continuously reevaluated

Background Notes

Click here

License

MIT