Chapter 9 ■ Forms
185 Also, note that the state variables are not exact copies of the object fields. We had to do some data type conversions after fetching the data, for example:
...
issue.created = new Date(issue.created).toDateString();
...
The above set of changes will result in an Edit page that looks as in Figure 9-2. It’s a good idea now to test it to ensure that all off the inputs are editable and faithfully replicate the user input.
Figure 9-2. Edit page placeholder replaced with a form
Chapter 9 ■ Forms
UI Components
Although we saved some repetitive code by combining all of the onChange handlers into one handler, it should be immediately obvious that the approach has some scope for improvement.
1. When dealing with non-string data types, when you want to validate (for example, check if the completion date is not before today), you need to convert the string representations to the natural data type. The same conversion is needed before sending the modified issue to the server.
2. If there is more than one input of the same type (number or date), you must repeat the conversions for each input.
3. The inputs let you type anything and don’t reject invalid numbers or dates. We already found that the HTML5 input types aren’t much help, and since the onChange handler is a common one, you can’t add masks there for different input types.
Ideally, we want the form’s state to store the fields in their natural data types (number, date, etc.). We also want all of the data type conversion routines to be shared.
A good solution to all this is to make reusable UI components for the non-string inputs, which emit natural data types in their onChange handlers. We could very well use some of the great packages like react-number-input and react-datepicker that provide these UI components. But for the sake of understanding how these UI components can be built, let’s create our own, minimalistic components.
We’ll first create a simple UI component for the number with simple validation and conversion. Then, we’ll create a more sophisticated UI component for the date, which does more, like letting the user know if and when the value is invalid.
Number Input
Since form inputs work well with strings, but we need a number in the state, the first thing we must do is separate the two states: one that represents the value of the persisted issue’s field and another that is the transient value, that is, the state while it’s being edited by the user. The persisted state can be the natural data type, and the transient one must be a string.
We’ll encapsulate the transient state along with all the other things needed to manage the editing and conversion within a UI component called NumInput. The transient state will be initialized to the persisted state (which will be owned by the parent component) when the component is mounted, via props. The two states will be synchronized as long as the input is not in the “being edited” state. Once the user starts editing (by placing the cursor and focusing on the input), the two states get disconnected.
This also lets the user type in a temporarily invalid value. It may not seem such a big deal for plain numbers, but as you’ll see in the next section, it is important for other data types like dates.
Chapter 9 ■ Forms
187 We can use the event of losing input focus from the input as a good indicator that the user is done editing. We can hook into this event by using an onBlur() handler in the input. It is at this point that we’ll emit the onChange() event to the parent. The actual onChange() handler within the NumInput component will only affect the local, or the transient, state. When the parent’s onChange() handler is called when the input loses focus, it will set its state (the persistent one) to the new value. At this point, the two states will be back in sync.
When calling the parent’s onChange, we will need to pass the validated, natural data type of the input. Instead of modifying the event’s target’s value (which doesn’t work), we’ll use a second argument to the onChange() handler to pass the value converted to the natural data type. An additional benefit is that the parent can choose to use the event’s target’s value or the converted value as it deems fit.
It’s also a good idea to let the parent specify some properties of the input field that can be passed through to the real HTML <input>. One useful property in this case is the size, which we set at 5 for the original input field. We’ll do this using the spread attribute construct of JSX. The new NumInput component is shown in Listing 9-10.
Listing 9-10. NumInput.jsx import React from 'react';
export default class NumInput extends React.Component { constructor(props) {
super(props);
this.state = { value: this.format(props.value) };
this.onBlur = this.onBlur.bind(this);
this.onChange = this.onChange.bind(this);
}
componentWillReceiveProps(newProps) {
this.setState({ value: this.format(newProps.value) });
}
onBlur(e) {
this.props.onChange(e, this.unformat(this.state.value));
}
onChange(e) {
if (e.target.value.match(/^\d*$/)) { this.setState({ value: e.target.value });
} }
format(num) {
return num != null ? num.toString() : '';
}
Chapter 9 ■ Forms unformat(str) {
const val = parseInt(str, 10);
return isNaN(val) ? null : val;
}
render() { return ( <input
type="text" {...this.props} value={this.state.value}
onBlur={this.onBlur} onChange={this.onChange}
/>
);
} }
NumInput.propTypes = {
value: React.PropTypes.number,
onChange: React.PropTypes.func.isRequired, };
Let’s examine a few statements in the listing in a bit more detail. First, let’s look at the format method of NumInput:
...
format(num) {
return num != null ? num.toString() : '';
} ...
Here, while converting the natural data type to a string, we are also dealing with the fact that the field is optional. A null value needs to be shown as an empty string. The corresponding unformat method does the same by checking if the parsed value is a number:
...
unformat(str) {
const val = parseInt(str, 10);
return isNaN(val) ? null : val;
} ...
An empty string would have resulted in isNaN being true. This also has the effect of rejecting possibly invalid values and setting them to null instead. But the regex check within onChange() on the input characters makes it very difficult to actually type in invalid values.
The two methods format() and unformat() are used to convert the natural data type to and from a string. We used format() when initializing the local state (in the constructor as well as the componentWillReceiveProps() lifecycle method), and we used unformat() in the onBlur() handler to convert to a natural data type before sending it to the parent.
Chapter 9 ■ Forms
189 Let’s also take a closer look at the spread attribute usage, which allowed us to pass an object that already has the properties directly to a component:
...
<input
type="text" {...this.props} value={this.state.value}
onBlur={this.onBlur} onChange={this.onChange}
/>
...
A construct of the form {...<object>} is the spread attribute. It places the key-value pairs of the supplied object as property-value pairs at that point. In the above code, we just passed through to the <input>, all the properties we received from the parent.
Note that we used {...this.props} after the type property but before the other properties like value. This is a way of indicating which properties can be overridden by the parent and which cannot. In JSX, if the same property is specified more than once, the last specification is the one that takes effect. Thus, if this.props contained a type property, it would take preference since it appears after the type specification in the input. Whereas if this.props contained value, onBlur, or onChange properties, they would not be passed through to the <input>. In fact, this.props does contain onChange and value properties, and they get overridden.
In order to use the new component, we need some changes to IssueEdit. We need to use NumInput instead of input for the effort field. Further, we need to change the onChange handler to look for an additional argument and use that if available. Finally, we can now use the natural data type in the issue object stored in the state, and the initial value can be null rather than an empty string. The modifications to the IssueEdit component are shown in Listing 9-11, with the changes highlighted.
Listing 9-11. IssueEdit.jsx: Changes for Using NumInput ...
import NumInput from './NumInput.jsx';
...
this.state = { issue: {
_id: '', title: '', status: '', owner: '', effort: null, ...
onChange(event, convertedValue) {
const issue = Object.assign({}, this.state.issue);
const value = (convertedValue !== undefined) ? convertedValue : event.target.value;
issue[event.target.name] = event.target.value;
issue[event.target.name] = value;
this.setState({ issue });
} ...