Building dynamic forms with Facebook React

In this post, I will demonstrate how to use Facebook React to build a simple form. It leverages the wingspan-forms library.

wingspan-forms is a a dynamic form library for Facebook React, providing abstractions for building dynamic forms and controlled grids. Widgets are provided by Telerik's KendoUI. wingspan-forms is about half "Kendo-React adapter", and half general functional-programming friendly form abstractions that aren't coupled to the underlying widget implementation. The higher level of abstraction is the important bit - this level of abstraction isn't possible in "just Kendo" or any other OOP style widget library.

Here is a live demo of the form we will build:

Lets start with a basic React component.

var MyForm = React.createClass({

    getInitialState: function () {
        return {
            firstName: 'hello world'
        };
    },

    render: function () {
        return (
            <div className="MyForm">
                <div>{this.props.firstName}</div>
                <pre>{JSON.stringify(this.state, undefined, 2)}</pre>
            </div>
        );
    }
});
React.renderComponent(<MyForm/>, document.body);

Next we will add the most basic widgets, KendoText. KendoText is basically the same as React's builtin controlled input component, with some minor enhancements.

render: function () {
    return (
        <div className="MyForm">
            <div>
                <KendoText value={this.state.firstName} onChange={this.onFirstNameChange} />
            </div>
            <pre>{JSON.stringify(this.state, undefined, 2)}</pre>
        </div>
    );
},

onFirstNameChange: function (value) {
    this.setState({ firstName: value });
}

Almost all form fields need at least a label, so lets wrap our KendoText in a FormField. FormField takes an isValid prop which sets some css for the invalid-style and the invalid-tooltip, as well as a fieldInfo prop for various optional metadata, like a label.

<FormField fieldInfo={{ "label": "First name" }} isValid={[false, 'This field is invalid']}>
    <KendoText value={this.state.firstName} onChange={this.onFirstNameChange} />
</FormField>

We know enough now to also use KendoDate, KendoNumber and KendoComboBox:

var MyForm = React.createClass({

    getInitialState: function () {
        return {
            firstName: '',
            lastName: '',
            gender: '', // due to a `wingspan-forms` bug, this has to be a string. Fix coming soon!
            age: null,
            birthday: null
        };
    },

    render: function () {
        return (
            <div className="MyForm">
                <div>
                    <FormField fieldInfo={{ "label": "First name" }} isValid={[true, '']}>
                        <KendoText value={this.state.firstName} onChange={this.onFirstNameChange} />
                    </FormField>

                    <FormField fieldInfo={{ "label": "Last name" }} isValid={[true, '']}>
                        <KendoText value={this.state.lastName} onChange={this.onLastNameChange} />
                    </FormField>

                    <FormField fieldInfo={{ "label": "Gender" }} isValid={[true, '']}>
                        <KendoComboBox
                            dataSource={[{ value: 'male', label: 'Male'}, { value: 'female', label: 'Female'}]}
                            displayField="label"
                            valueField="value"
                            value={this.state.gender}
                            onChange={this.onGenderChange} />
                    </FormField>

                    <FormField fieldInfo={{ "label": "Age" }} isValid={[true, '']}>
                        <KendoNumber value={this.state.age} onChange={this.onAgeChange} />
                    </FormField>

                    <FormField fieldInfo={{ "label": "Birthday" }} isValid={[true, '']}>
                        <KendoDate value={this.state.birthday} onChange={this.onBirthdayChange} />
                    </FormField>
                </div>
                <pre>{JSON.stringify(this.state, undefined, 2)}</pre>
            </div>
        );
    },

    onFirstNameChange: function (value) {
        this.setState({ firstName: value });
    },
    onLastNameChange: function (value) {
        this.setState({ lastName: value });
    },
    onAgeChange: function (value) {
        this.setState({ age: value });
    },
    onBirthdayChange: function (value) {
        this.setState({ birthday: value });
    },
    onGenderChange: function (value) {
        this.setState({ gender: value });
    }
});

React doesn't yet have a way to use React components that are namespaced, so the following JSX does not work:

<WingspanForms.KendoComboBox ... />

Until the React folks decide on a mechanism to allow this, we have to alias the components into the local scope. We can do this out of the way, at the bottom of the file, due to JavaScript's var hoisting.

var FormField = WingspanForms.FormField;
var KendoText = WingspanForms.KendoText;
var KendoComboBox = WingspanForms.KendoComboBox;
var KendoNumber = WingspanForms.KendoNumber;
var KendoDate = WingspanForms.KendoDate;

That's an awful lot of boilerplate, so lets introduce some new abstractions. wingspan-forms provides AutoControl, which figures out which widget to render by inspecting the fieldInfo.

var birthdayFieldInfo = { type: 'date', label: 'Birthday'};

<FormField fieldInfo={birthdayFieldInfo} key={this.props.fieldInfo.name} isValid={[true, '']}>
    <AutoControl
        fieldInfo={birthdayFieldInfo}
        value={this.state.birthday}
        onChange={this.onBirthdayChange} />
</FormField>

We can simplify this further with AutoField, which simply composes AutoControl and FormField.

<AutoField
    fieldInfo={birthdayFieldInfo}
    value={this.props.value[fieldInfo.name]}
    onChange={this.onBirthdayChange}
    isValid={[true, '']} />

Let's use our functional programming chops to abstract even further, and make all the fields use the same onChange callback. Note the 'gender' fieldInfo got a little more complicated, since KendoComboBox requires configuration.

var fieldInfos = {
        firstName: { type: 'text', label: 'First Name' },
        lastName: { type: 'text', label: 'Last Name' },
        gender: {
            dataType: 'enum', label: 'Gender', name: 'gender',
            options: {
                metadata: { idProperty: 'value', nameProperty: 'label'},
                dataSource: [{ value: 'male', label: 'Male'}, { value: 'female', label: 'Female'}]
            }
        },
        age: { type: 'number', label: 'Age' },
        birthday: { type: 'date', label: 'Birthday'}
};

render: function () {
    var controls = _.map(fieldInfos, function (fieldInfo) {
        return (
            <AutoField
                fieldInfo={fieldInfo}
                value={this.state[fieldInfo.name]}
                onChange={_.partial(this.onFieldChange, fieldInfo.name)}
                isValid={[true, '']} />
            );
    }.bind(this));

    return (
        <div className="MyForm">
            <div>{controls}</div>
            <pre>{JSON.stringify(this.state, undefined, 2)}</pre>
        </div>
    );
},

onFieldChange: function (fieldName, value) {
    this.setState(_.object([[fieldName, value]]));
}

You could abstract further. Try implementing AutoForm.

Lets add some validation. Note the AutoField's isValid prop changed.

render: function () {
    var controls = _.map(fieldInfos, function (fieldInfo) {
        return (
            <AutoField
                fieldInfo={fieldInfo}
                value={this.state[fieldInfo.name]}
                onChange={_.partial(this.onFieldChange, fieldInfo.name)}
                isValid={this.isFieldValid(fieldInfo.name)} />
            );
    }.bind(this));

    return (
        <div className="MyForm">
            <div>{controls}</div>
            <pre>{JSON.stringify(this.state, undefined, 2)}</pre>
        </div>
    );
},

isFieldValid: function (fieldName) {
    var val = this.state[fieldName];
    return val !== null && val !== ''
        ? [true, '']
        : [false, 'This field is required.'];
}

There you have it: a production-ready dynamic form in about 40 lines of code. Here is the full source code to this example on github, and as a fiddle.