Two-way binding is back, and this time it's respectable
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.
Gradually growing... click here
npm install bidi-mobx
You may need to define mobx as an external in your Webpack config to avoid it being duplicated
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).
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.
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);
}
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.
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
andonChange
features instead of a singleBoxedValue
- Validation is an explicitly requested command, not continuously reevaluated
MIT