Forms: Pattern Construction

WARNING! v0.13 was just released, and the tutorial is currently undergoing final testing. It's recommended that you DO NOT follow along until this message is removed (please check back tomorrow).

Mental model for the forms implementation

1. Refactor Fields

In this step we'll make small change to refactor our fields, and eliminate the onBlur callback from our forms.

You can view the finished code for this step by checking out the create.1 branch of the completed project.

What's the problem?

Take a look at the code below, which reflects the field to capture text input:

<div className={`form-group ${touched['text'] && errors['text'] ? 'has-error' : ''}`}>
  <label>
    Message
  </label>
  <textarea
    className="form-control"
    rows="3"
    value={data.text}
    placeholder="What's happening?"
    onChange={(event) => {
      this.onChange('text', event.target.value)
    }}
    onBlur={() => {
      this.onBlur('text');
    }}
  />
  {touched['text'] && errors['text'] ? (
    <span className="help-block">
      {errors['text']}
    </span>
  ) : null}
</div>

One concern with this code is the number of places where the name of the field appears, which is text in this example. For example:

  • touched['text'] && errors['text']
  • data.text
  • this.onChange('text', event.target.value)
  • this.onBlur('text')
  • touched['text'] && errors['text']
  • errors['text']

In this case, there are 8 places where text appears, and it's not hard to picture a scenario where a bug is accidentally introduced just by mistyping the name, or forgetting to add or update one of those locations.

How do we solve it?

In this step, we're going to solve that by introducing a component called Field that is part of the lore-react-forms package. After introducing this component, we'll only need to specify the name of the field once, and reduce the risk of copy/paste errors.

You can learn more about the Field component here.

Import Field

Start by importing Field from lore-react-forms.

// src/dialogs/tweet/create/index.js
import { Field } from 'lore-react-forms';
...

Then refactor the text field to look like this:

// src/dialogs/tweet/create/index.js
<div className="row">
  <div className="col-md-12">
    <Field name="text" data={data} errors={errors} onChange={this.onChange}>
      {(field) => {
        return (
          <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
            <label>
              Message
            </label>
            <textarea
              className="form-control"
              rows="3"
              value={field.value}
              placeholder="What's happening?"
              onChange={(event) => {
                field.onChange(event, event.target.value)
              }}
              onBlur={field.onBlur}
            />
            {field.touched && field.error ? (
              <span className="help-block">
                {field.error}
              </span>
            ) : null}
          </div>
        );
      }}
    </Field>
  </div>
</div>

And then refactor the userId field to look like this:

// src/dialogs/tweet/create/index.js
<div className="row">
  <div className="col-md-12">
    <Field name="userId" data={data} errors={errors} onChange={this.onChange}>
      {(field) => {
        return (
          <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
            <label>
              User
            </label>
            <Connect callback={(getState, props) => {
              return {
                options: getState('user.find')
              };
            }}>
              {(connect) => {
                return (
                  <select
                    className="form-control"
                    value={field.value}
                    onChange={(event) => {
                      const value = event.target.value;
                      field.onBlur();
                      field.onChange(event, value ? Number(value) : undefined)
                    }}
                  >
                    {[<option key="" value=""/>].concat(connect.options.data.map((datum) => {
                      return (
                        <option key={datum.id} value={datum.id}>
                          {datum.data.nickname}
                        </option>
                      );
                    }))}
                  </select>
                )
              }}
            </Connect>
            {field.touched && field.error ? (
              <span className="help-block">
                {field.error}
              </span>
            ) : null}
          </div>
        );
      }}
    </Field>
  </div>
</div>

Review

This wasn't a big change, but it does provide a few benefits.

For starters, we no longer need to use the name of the field to access things like the value of the field, which simplifies some of the code:

  • touched['text'] && errors['text'] becomes field.touched && field.error
  • data.text becomes field.value
  • this.onChange('text', event.target.value) becomes field.onChange(event, event.target.value)
  • this.onBlur('text') becomes field.onBlur
  • errors['text'] becomes field.error

Additionally, the Field is now keeping track of whether the component has been touched or not, which means we no longer need to keep track of touched in the forms state, and can also delete the onBlur() callback.

So now, instead of referencing the name of the field, we only need to tell the Field what it's called, and the component will automatically map data into value and errors into error.

Visual Check-in

If everything went well, clicking the create button will still launch a dialog that you can use to create a new tweet.

Code Changes

Below is a list of files modified during this step.

src/dialogs/tweet/create/index.js

import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { Connect } from 'lore-hook-connect';
import { Field } from 'lore-react-forms';

export default createReactClass({
  displayName: 'CreateTweetDialog',

  propTypes: {
    onCancel: PropTypes.func
  },

  getInitialState() {
    return {
      data: {
        text: '',
        userId: undefined
      }
    };
  },

  request(data) {
    lore.actions.tweet.create(data);
  },

  onSubmit() {
    const { data } = this.state;
    this.request(data);
    this.dismiss();
  },

  dismiss() {
    this.props.onCancel();
  },

  onChange(name, value) {
    const nextData = _.merge({}, this.state.data);
    nextData[name] = value;
    this.setState({
      data: nextData
    });
  },

  getValidators: function(data) {
    return {
      text: [function(value) {
        if (!value) {
          return 'This field is required';
        }
      }],
      userId: [function(value) {
        if (value === undefined) {
          return 'This field is required'
        }
      }]
    };
  },

  getErrors: function(validatorDictionary, data) {
    return _.mapValues(data, function(value, key) {
      const validators = validatorDictionary[key];
      let error = null;
      if (validators) {
        validators.forEach(function(validator) {
          error = error || validator(value);
        });
      }
      return error;
    });
  },

  hasError: function(errors) {
    const errorCount = _.reduce(errors, function(result, value, key) {
      if (value) {
        return result + 1;
      }

      return result;
    }, 0);

    return errorCount > 0;
  },

  render() {
    const { data } = this.state;
    const validators = this.getValidators(data);
    const errors = this.getErrors(validators, data);
    const hasError = this.hasError(errors);

    return (
      <div className="modal-dialog">
        <div className="modal-content">
          <div className="modal-header">
            <button type="button" className="close" onClick={this.dismiss}>
              <span>&times;</span>
            </button>
            <h4 className="modal-title">
              Create Tweet
            </h4>
          </div>
          <div className="modal-body">
            <div className="row">
              <div className="col-md-12">
                <Field name="text" data={data} errors={errors} onChange={this.onChange}>
                  {(field) => {
                    return (
                      <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
                        <label>
                          Message
                        </label>
                        <textarea
                          className="form-control"
                          rows="3"
                          value={field.value}
                          placeholder="What's happening?"
                          onChange={(event) => {
                            field.onChange(event, event.target.value)
                          }}
                          onBlur={field.onBlur}
                        />
                        {field.touched && field.error ? (
                          <span className="help-block">
                            {field.error}
                          </span>
                        ) : null}
                      </div>
                    );
                  }}
                </Field>
              </div>
            </div>
            <div className="row">
              <div className="col-md-12">
                <Field name="userId" data={data} errors={errors} onChange={this.onChange}>
                  {(field) => {
                    return (
                      <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
                        <label>
                          User
                        </label>
                        <Connect callback={(getState, props) => {
                          return {
                            options: getState('user.find')
                          };
                        }}>
                          {(connect) => {
                            return (
                              <select
                                className="form-control"
                                value={field.value}
                                onChange={(event) => {
                                  const value = event.target.value;
                                  field.onBlur();
                                  field.onChange(event, value ? Number(value) : undefined)
                                }}
                              >
                                {[<option key="" value=""/>].concat(connect.options.data.map((datum) => {
                                  return (
                                    <option key={datum.id} value={datum.id}>
                                      {datum.data.nickname}
                                    </option>
                                  );
                                }))}
                              </select>
                            )
                          }}
                        </Connect>
                        {field.touched && field.error ? (
                          <span className="help-block">
                            {field.error}
                          </span>
                        ) : null}
                      </div>
                    );
                  }}
                </Field>
              </div>
            </div>
          </div>
          <div className="modal-footer">
            <div className="row">
              <div className="col-md-12">
                <button
                  type="button"
                  className="btn btn-default"
                  onClick={this.dismiss}
                >
                  Cancel
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  disabled={hasError}
                  onClick={this.onSubmit}
                >
                  Create
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }

});

Next Steps

Next we're going to refactor the form.