Why is building forms in React difficult

  • Toni Petrina
  • Visma e-conomic a/s
  • github.com/tpetrina
  • @to_pe

Agenda

  • Introduction
  • Complications
  • Potential solutions
  • Conclusion

Your name is

Form with state

class SimpleForm extends React.PureComponent {
state = { name: "" };
render() {
const { name } = this.state;
return (
<form>
<div>
<label htmlFor="name">First name:</label>
<input
type="text"
id="name"
value={name}
onChange={e => this.setState({ name: e.target.value })}
/>
</div>
<input type="submit" value="send" />
<hr />
Your name is {name}
</form>
);
}
}

Using state is easy!

Form with hooks

function SimpleFormHooks() {
const [name, setName] = useState("");
return (
<form>
<div>
<label htmlFor="name">First name:</label>
<input
type="text"
id="name"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<input type="submit" value="send" />
<hr />
Your name is {name}
</form>
);
}

Using hooks is easier!

Let's recap

  • Initial value
  • Update value
  • Use the current value

Simplification

function Input({ label, value, onChange }) {
return (
<div>
<label>{label}</label>
<input type="text" value={value} onChange={onChange} />
</div>
);
}
class SimpleForm extends React.Component {
render() {
const { name } = this.state;
return (
<Input
label="Enter your name:"
value={name}
onChange={e => this.setState({ name: e.target.value })}
/>
);
}
}

Digression: a simple placeholder

<input type='text'
placeholder='Enter your name please...'
/>

Scaling up!


User: , , .

Complex form

import React from 'react';
export default class SimpleForm2 extends React.PureComponent {
state = { name: '', email: '', address: '', quantity: 0 };
render() {
const { name, email, address, quantity } = this.state;
return (
<form onSubmit={this.submit}>
<div>
<label htmlFor="quantity">Quantity:</label>
<input
type="number"
id="quantity"
min={0}
max={5}
step={1}
value={quantity}
onChange={e =>
this.setState({ quantity: parseInt(e.target.value, 10) })
}
/>
</div>
<div>
<label htmlFor="firstName">First name:</label>
<input
type="text"
id="firstName"
value={name}
maxLength={10}
onChange={e => this.setState({ name: e.target.value })}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
value={email}
onChange={e => this.setState({ email: e.target.value })}
/>
</div>
<div>
<label htmlFor="address">Address:</label>
<input
type="text"
id="address"
value={address}
required
onChange={e => this.setState({ address: e.target.value })}
/>
</div>
<input type="submit" value="send" />
<hr />
User: {name}, {email}, {address}.
</form>
);
}
submit = e => {
e.preventDefault();
alert('huh?');
};
}

More realistic example!

But before we start simplifying, let's handle requests...


User: , john@smith.com, 1st street.

Now we'll handle it!


User: , john@smith.com, 1st street.

Async form

import React from 'react';
import { sendRequest } from './sendRequest';
export default class SimpleForm4 extends React.PureComponent {
state = {
isSending: false,
name: '',
email: 'john@smith.com',
address: '1st street',
quantity: 1,
};
render() {
const { isSending, name, email, address, quantity } = this.state;
return (
<form onSubmit={this.submit}>
<div>
<label htmlFor="quantity">Quantity:</label>
<input
type="number"
id="quantity"
min={0}
max={5}
step={1}
value={quantity}
onChange={e =>
this.setState({ quantity: parseInt(e.target.value, 10) })
}
/>
</div>
<div>
<label htmlFor="name">First name:</label>
<input
type="text"
id="name"
value={name}
maxLength={10}
onChange={e => this.setState({ name: e.target.value })}
/>
</div>
<div>
<label htmlFor="email">First name:</label>
<input
type="email"
id="email"
value={email}
onChange={e => this.setState({ email: e.target.value })}
/>
</div>
<div>
<label htmlFor="address">Address:</label>
<input
type="text"
id="address"
value={address}
required
onChange={e => this.setState({ address: e.target.value })}
/>
</div>
<input type="submit" value="send" disabled={isSending} />
<hr />
User: {name}, {email}, {address}.
</form>
);
}
submit = e => {
e.preventDefault();
this.setState({ isSending: true });
sendRequest()
.then(() => {})
.catch(() => {})
.then(() => this.setState({ isSending: false }));
};
}

Async is annoying!

Our forms are a mix of:

  • presentation: label text, placeholder
  • state management for values
  • handling async state
  • basic validation

...and there is more

Simplification?

function Input({ className, label, id, placeholder, type, value, onChange }) {
return (
<div className={className || ''}>
<label htmlFor={id}>{label}</label>
<input
id={id}
value={value}
onChange={onChange}
placeholder={placeholder}
type={type}
/>
</div>
);
}
export const Usage = () => (
<Input
id="firstName"
label="Enter your name:"
value={name}
placeholder="Your name goes here..."
type="text"
onChange={e => this.setState({ name: e.target.value })}
/>
);

Simplification? :(

function Input({ className, children, id, label, ...rest }) {
return (
<div className={className || ''}>
<label htmlFor={id}>{label}</label>
<input {...rest} />
</div>
);
}
export const Usage = () => (
<Input
id="firstName"
label="Enter your name:"
value={name}
placeholder="Your name goes here..."
type="text"
onChange={e => this.setState({ name: e.target.value })}
/>
);

Validation

We have several kind

  • built-in field validation (on DOM)
  • custom field validation
  • async validation from server
  • entire form validation

Client validation

Define a set of rules, check form input and notify in case of any errors.

Simple client validation

import React from 'react';
const ErrorField = ({ error }) => {
if (!error) return null;
return <span style={{ color: 'red' }}>{error}</span>;
};
export default class ValidationForm1 extends React.PureComponent {
state = {
field1: '',
field2: '',
field3: '',
field4: '',
errors: { field1: '', field2: '', field3: '', field4: '' },
};
render() {
return (
<form onSubmit={this.submit} style={{ textAlign: 'left' }}>
<div>
<label>
I am always ok
<input
type="text"
value={this.state.field1}
onChange={e => this.setState({ field1: e.target.value })}
/>
<ErrorField error={this.state.errors.field1} />
</label>
</div>
<div>
<label>
I will let you know if things fail
<input
type="text"
value={this.state.field2}
onChange={e => this.setState({ field2: e.target.value })}
/>
<ErrorField error={this.state.errors.field2} />
</label>
</div>
<div>
<label>
I am eager to let you know I am ok
<input
type="text"
value={this.state.field3}
onChange={e => this.setState({ field3: e.target.value })}
onBlur={this.validateField3}
/>
<ErrorField error={this.state.errors.field3} />
</label>
</div>
<div>
<label>
Don't leave me!
<input
type="text"
value={this.state.field4}
onChange={e => this.setState({ field4: e.target.value })}
onBlur={this.validateField4}
/>
<ErrorField error={this.state.errors.field4} />
</label>
</div>
<div>
<input type="submit" disabled={!this.canSubmit()} />
</div>
</form>
);
}
canSubmit = () =>
!this.state.errors.field1 &&
!this.state.errors.field2 &&
!this.state.errors.field3 &&
!this.state.errors.field4;
submit = e => {
e.preventDefault();
if (this.state.field2.indexOf('u') !== -1) {
this.setError('field2', "We don't like u");
} else {
this.setError('field2', '');
}
};
validateField3 = e => {
const value = e.target.value;
if (value.indexOf('u') !== -1) {
this.setError('field3', "We don't like u");
} else {
this.setError('field3', '');
}
};
validateField4 = e => {
const value = e.target.value;
if (!value) {
this.setError('field4', 'This value is required');
} else if (value.indexOf('u') !== -1) {
this.setError('field4', 'This value has invalid elements: u');
} else {
this.setError('field4', '');
}
};
setError = (field, error) =>
this.setState(({ errors }) => ({
errors: { ...errors, [field]: error },
}));
}

Client validation involves multiple parts

UX considerations

  • When to display warning, error or success indicator
  • Where to display messages for composite errors involving multiple fields
  • When to clear it
  • Do we prevent form submission
  • Help users before they make a mistake through help instructions or character counters

Server side validation

Once the server responds with an error, it can be

  • field specific - potentially fixable issue in this field
  • general error - potentially fixable issue in this form

Our forms are a mix of:

  • presentation: label text, placeholder, constraints
  • state management for values
  • handling async state
  • client validation
  • server validation/errors
{
  'name': 'email',
  'type: 'email',
  'rules': {'required', 'email regex', 'maxLength=50'}
}

General approaches

  • Bunch of basic elements like TextInput, PasswordInput
  • Hack it with React.Children.map
  • Generic Field component with custom rendering

Children rewrite

<BindingContext data={this.state} set={this.set}>
<TextInput name="appAlias" />
<TextInput name="deviceToken" />
<Select name="mobileDeviceType">
<option value="1">Android</option>
<option value="2">iOS</option>
</Select>
<Checkbox name="isDemo" />
<Checkbox name="isDev" />
</BindingContext>
export const BindingContext = ({ data, set, children }) => (
<>
{transformChildren(children, child =>
// skip built in types
!!child && !!child.type && typeof child.type !== 'string'
? React.cloneElement(child, {
data,
set,
})
: child
)}
</>
);
function transformChildren(children, apply) {
return React.Children.map(children, (child, index) => {
let newChild = apply(child, index);
if (newChild.props && newChild.props.children)
return React.cloneElement(newChild, {
children: transformChildren(newChild.props.children, apply),
});
return newChild;
});
}

Simple usage

But there are third-party libraries for this:

  • formik
  • react-final-form (react-final-form-hooks)
  • redux-forms

Formik

import React from 'react';
import Dialog from 'MySuperDialog';
import { Formik, Field, Form, ErrorMessage } from 'formik';
export const EditUserDialog = ({ user, updateUser, onClose }) => {
return (
<Dialog onClose={onClose}>
<h1>Edit User</h1>
<Formik
initialValues={user /** { email, social } */}
onSubmit={(values, actions) => {
MyImaginaryRestApiCall(user.id, values).then(
updatedUser => {
actions.setSubmitting(false);
updateUser(updatedUser);
onClose();
},
error => {
actions.setSubmitting(false);
actions.setErrors(transformMyRestApiErrorsToAnObject(error));
actions.setStatus({ msg: 'Set some arbitrary status or data' });
}
);
}}
render={({ errors, status, touched, isSubmitting }) => (
<Form>
<Field type="email" name="email" />
<ErrorMessage name="email" component="div" />
<Field type="text" className="error" name="social.facebook" />
<ErrorMessage name="social.facebook">
{errorMessage => <div className="error">{errorMessage}</div>}
</ErrorMessage>
<Field type="text" name="social.twitter" />
<ErrorMessage
name="social.twitter"
className="error"
component="div"
/>
{status && status.msg && <div>{status.msg}</div>}
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</Form>
)}
/>
</Dialog>
);
};

Formik

Redux form

import React from 'react';
import { Field, reduxForm } from 'redux-form';
const validate = values => {
const errors = {};
if (!values.username) {
errors.username = 'Required';
} else if (values.username.length > 15) {
errors.username = 'Must be 15 characters or less';
}
if (!values.email) {
errors.email = 'Required';
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) {
errors.email = 'Invalid email address';
}
if (!values.age) {
errors.age = 'Required';
} else if (isNaN(Number(values.age))) {
errors.age = 'Must be a number';
} else if (Number(values.age) < 18) {
errors.age = 'Sorry, you must be at least 18 years old';
}
return errors;
};
const warn = values => {
const warnings = {};
if (values.age < 19) {
warnings.age = 'Hmm, you seem a bit young...';
}
return warnings;
};
const renderField = ({
input,
label,
type,
meta: { touched, error, warning },
}) => (
<div>
<label>{label}</label>
<div>
<input {...input} placeholder={label} type={type} />
{touched &&
((error && <span>{error}</span>) ||
(warning && <span>{warning}</span>))}
</div>
</div>
);
const SyncValidationForm = props => {
const { handleSubmit, pristine, reset, submitting } = props;
return (
<form onSubmit={handleSubmit}>
<Field
name="username"
type="text"
component={renderField}
label="Username"
/>
<Field name="email" type="email" component={renderField} label="Email" />
<Field name="age" type="number" component={renderField} label="Age" />
<div>
<button type="submit" disabled={submitting}>
Submit
</button>
<button type="button" disabled={pristine || submitting} onClick={reset}>
Clear Values
</button>
</div>
</form>
);
};
export default reduxForm({
form: 'syncValidation', // a unique identifier for this form
validate, // <--- validation function given to redux-form
warn, // <--- warning function given to redux-form
})(SyncValidationForm);

Redux form

React Final Form Hooks

import { useForm, useField } from 'react-final-form-hooks';
export const MyForm = () => {
const { form, handleSubmit, values, pristine, submitting } = useForm({
onSubmit, // the function to call with your form values upon valid submit
validate, // a record-level validation function to check all form values
});
const firstName = useField('firstName', form);
const lastName = useField('lastName', form);
return (
<form onSubmit={handleSubmit}>
<div>
<label>First Name</label>
<input {...firstName.input} placeholder="First Name" />
{firstName.meta.touched && firstName.meta.error && (
<span>{firstName.meta.error}</span>
)}
</div>
<div>
<label>Last Name</label>
<input {...lastName.input} placeholder="Last Name" />
{lastName.meta.touched && lastName.meta.error && (
<span>{lastName.meta.error}</span>
)}
</div>
<button type="submit" disabled={pristine || submitting}>
Submit
</button>
</form>
);
};

React Final Form

Conclusion

  • Forms are hard
  • All solutions are complex because the problem is complex
  • Do standardize on something!
  • Follow good UX practices