React Form Container
Yes! It is finally time for some form making! Whenever you build a form, many concerns starts to bubble up: input fields, validation, submission flow, server error handling. Rest assured, where there's a redux-form, there's a way.
Redux-Form
Redux-form is a Higher-Order Component (HOC) that manages the form-state, validation, and submission process.
Higher-Order Component (HOC) is a wrapper that injects prop methods & fields into its child component. You'd usually see HOC in action in the line where the component is being exported. (ie. redux's connect()(Component) is a HOC)
Create a Recipe Form Container
In the src/containers
directory, we will create a RecipeForm
directory. In that directory, we'll have four files: index.js
(exports container), RecipeForm.js
(holds the form view), and RecipeFormContainer.js
(manages communication between view and redux), messages.js (holds translation).
In file index.js
, we'll export the container (snippet: imi
):
/* @flow */
import RecipeFormContainer from './RecipeFormContainer';
export default RecipeFormContainer;
In file RecipeForm.js, we'll construct the form using redux-form (snippet: rrfc
):
/* @flow */
import React, {Component} from 'react';
import {reduxForm, Field, FieldArray} from 'redux-form';
import {
TextField,
SelectField
} from 'redux-form-material-ui';
import {RaisedButton, FlatButton, MenuItem} from 'material-ui';
const validate = values => {
const errors = {};
/*
The errors object follows the form structure, with _error for global errors.
In this case, errors can hold three fields:
- _error
- name
- ingredients (array of):
- name
- amount
- unit
*/
if (!values.name) {
errors.name = 'This is required';
}
// Loop through each ingredient and validate its name, amount, and unit
if (values.ingredients) {
let ingredientsArrayErrors = [];
values.ingredients.forEach((ingredient, ingredientIndex) => {
let ingredientErrors = {};
if (!ingredient.name) {
ingredientErrors.name = 'This is required';
}
if (isNaN(ingredient.amount)) {
ingredientErrors.amount = 'This needs to be a number';
}
if (!ingredient.unit || !/^(tsp|tbsp|cup)$/i.test(ingredient.unit)) {
ingredientErrors.unit = 'This is an invalid value';
}
// if there are any errors, add it to the array of errors
if (Object.keys(ingredientErrors).length > 0) {
ingredientsArrayErrors[ingredientIndex] = ingredientErrors;
}
});
if (ingredientsArrayErrors.length > 0) {
errors.ingredients = ingredientsArrayErrors;
}
}
return errors;
};
// We can create custom fields for redux-form. In here, we are rendering the field for redux-form's FieldArray component
const renderIngredients = ({fields, meta: {touched, error}}) => (
<div>
<div>
<button type="button" onClick={() => fields.push({})}>Add Ingredient</button>
{touched && error && <span>{error}</span>}
</div>
<div>
{fields.map((ingredient, index) =>
<div key={index}>
<button onClick={() => fields.remove(index)}>Remove Ingredient</button>
<h4>Ingredient #{index + 1}</h4>
<div>
<Field name={`${ingredient}.name`} component={TextField} floatingLabelText={'Name'} />
</div>
<div>
<Field name={`${ingredient}.amount`} type="number" min="0" step={0.25} defaultValue="1" component={TextField} floatingLabelText={'Amount'} />
</div>
<div>
<Field name={`${ingredient}.unit`} component={SelectField} hintText="Select a unit">
<MenuItem value="tsp" primaryText="Teaspoon(s)"/>
<MenuItem value="tbsp" primaryText="Tablespoon(s)"/>
<MenuItem value="cup" primaryText="Cup(s)"/>
</Field>
</div>
</div>
)}
</div>
</div>
);
class RecipeForm extends Component {
static propTypes = {
};
render() {
const {error, handleSubmit, pristine, reset, submitting} = this.props;
return (
<form onSubmit={handleSubmit}>
{error && <strong>{error}</strong>}
<div>
<Field name="name" component={TextField} floatingLabelText={'name'} />
</div>
<br />
<br />
<FieldArray name="ingredients" component={renderIngredients}/>
<br />
<br />
<RaisedButton type="submit" disabled={submitting} primary={true}>Add Recipe</RaisedButton>
<FlatButton disabled={pristine || submitting} onClick={reset}>Clear</FlatButton>
</form>
);
}
}
export default reduxForm({
form: 'Recipe',
validate
})(RecipeForm);
What is happening here is reduxForm enhances our RecipeForm by wrapping it at the export area. In this wrap, it defines the form name to be stored in redux, a validate function to handle errors, and it injects a set of fields and functions into this.props
. Some of the more noteworthy fields are handleSubmit
, error
and reset
. handleSubmit
captures the onSubmit action, modifies its own fields, and calls this.props.onSubmit
which is passed down from the parent component in RecipeFormContainer.js
. error
shows the list of errors and reset
is a function that resets the form. Inside RecipeForm's render function, we use the components Field and FieldArray, which is provided by redux-form. One of the great thing redux-form offers is the flexibility in our form components. Both Field and FieldArray provides a light wrapper that transcends required fields and methods down to the actual component you use to handle user input. Take <FieldArray name="ingredients" component={renderIngredients} />
as an example; The name describes the field it holds in the form, and component points to the renderIngredients function above to render the view based on several fields passed into it from FieldArray
.
In file RecipeFormContainer.js, connect onSubmit to a action that'll either say it succeeded or server threw an error and display it on the form (snippet: rcc
):
/* @flow */
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ActionCreators} from 'actions';
import RecipeForm from './RecipeForm';
class RecipeFormContainer extends React.Component {
constructor(props: Object): void {
super(props);
}
onSubmit(recipe) {
console.log('recipe', recipe);
}
render() {
return (
<RecipeForm onSubmit={this.onSubmit.bind(this)} />
);
}
}
function mapStateToProps(state: Object): {} {
return {
};
}
function mapDispatchToProps(dispatch: Function): {Actions: Object} {
return {Actions: bindActionCreators(ActionCreators, dispatch)};
}
export default connect(mapStateToProps, mapDispatchToProps)(RecipeFormContainer);
As said before, container is just a view wrapper and is only for handling business logic. In the render method, we declare RecipeForm and connects its onSubmit method to this class' onSubmit method. Now when the 'Add Recipe' button is clicked, we should see the console.log display our recipe form object.
In file routes/Root/RecipeForm/RecipeFormPage.js
, import Recipeform container and add it to the render method:
/* @flow */
import React, {Component} from 'react';
import RecipeForm from 'containers/RecipeForm';
export default class RecipeFormPage extends Component {
static propTypes = {
};
render() {
return (
<div>
<RecipeForm />
</div>
);
}
}
Test Result
If you don't have api and web running, hit $ npm start
on both. In the browser, go to http://localhost:3000/recipes/create. You should now see a recipe form. Try to fill it up and press 'Add Recipe'. Open developer's tools f12 and navigate to console; You should see a console log showing the complete recipe form object.