In this tutorial, we are going to implement a simple React library for creating, composing and serializing form elements.
The goal is to allow developers to create forms with minimal code amount and maximal flexibility of the composition. The library should have the built-in validation and return serialized data on submit. Using the library shouldn’t require any extra code but simple declaration of an order, types, names and validation rules. The whole internal data flow will be hidden in the implementation.
Let’s get started.
Our tech stack
One thing I love in React the most is its simplicity. It allows me to write my views as reusable components being functions of the state – I give them the model and they render the view, nothing more. React takes full responsibility for re-rendering, managing the view transitions and this is done in a very efficient way.
What React cannot do for me though is state and data managing. Remember – it’s only a view library, we have to organise the data flow around the views separately.
Fortunately, React works perfectly with well-known patterns commonly used for a long time. We’re using redux as a way to manage data flow.
To use redux, we have to implement two elements: action creator and state reducer. These names might sound scary, but the idea is really simple.
To fully understand how redux works and how powerful it can be, I recommend watching the presentation and lessons on egghead by Dan Abramov, the author of redux.
To simplify the whole process, we will use ready components from the Material UI to avoid extra styling and focus on composing and validation of the form.
Bootstrapping the project
First let’s set up the project and the environment. I assume you have node already installed. Create a new folder and initialize the new npm project using npm init
.
You can either follow along in this article, or view the final result here
We need a tool for building your scripts into the one minified file – webpack is IMO an excellent choice for this purpose. Code will be written in ES2015 (aka ES6) and JSX syntax, so you also need the babel plugin to convert the code into a version readable for all modern browsers.
Begin by installing webpack globally: npm i -g webpack
. Then install plugins and all required dependencies locally: npm i --save-dev webpack@^1.0.0 babel-loader babel-preset-es2015 babel-preset-react path
.
Create a new file called build.config.js
, containing the following configuration script:
var path = require('path');
var webpack = require('webpack');
module.exports = {
context: __dirname, //current folder as the reference to the other paths
entry: {
demo: './demo.js' //entry point for building scripts
},
output: {
path: path.resolve('./dist'), //save result in 'dist' folder
filename: 'demo.js'
},
module: {
loaders: [
{ //transpile ES2015 with JSX into ES5
test: /\.js?$/,
exclude: /node_modules/,
loader: 'babel',
query: {
presets: ['react', 'es2015']
}
}
]
}
};
Add build aliases at the end of package.json
:
...
"scripts": {
"watch": "webpack --progress --colors --watch --config ./build.config.js",
"build": "webpack --config ./build.config.js"
}
...
Install react modules: npm i -S react react-dom
. Create a file called demo.js
– we will import modules from the library there and render the example form to see the results. For now it renders an empty div
:
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(<div/>, document.getElementById('container'));
To run the demo, we have to open an html file. Create a folder called dist
and put file demo.html
with simple markup loading the script:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Demo</title>
</head>
<body>
<div id="container"></div>
<script src="demo.js"></script>
</body>
</html>
Now you can run build process with the watcher to see if it works: npm run watch
Webpack will compile the demo and start watching for changes in the code. You should see output similar to the one below:
> tutorial@1.0.0 watch /Users/kasperwargula/dev/tutorial
> webpack --progress --colors --watch --config ./build.config.js
Hash: f2a3c2238529cf4f0c90
Version: webpack 1.12.12
Time: 1421ms
Asset Size Chunks Chunk Names
demo.js 676 kB 0 [emitted] demo
+ 159 hidden modules
Congratulations! The environment is ready for coding.
Form component
Create the first component called Form
. It’s going to be the root component for fields nested inside:
// src/components/Form.js
import React, {PropTypes} from 'react';
export default React.createClass({
displayName: 'Form',
propTypes: {
children: PropTypes.node,
values: PropTypes.object,
update: PropTypes.func,
reset: PropTypes.func,
onSubmit: PropTypes.func
},
render() {
return (
<form> {this.props.children} </form>
);
}
});
The code above doesn’t add any extra value when we compare it with using the simple <form>...</form>
. Our component should store the data from its children. To achieve this, we have to connect this component to redux.
Start with creating constants for action types. We will use them to define and recognize actions in action creators and the store:
// src/constants.js
export const FORM_UPDATE_VALUE = 'FORM_UPDATE_VALUE';
export const FORM_RESET = 'FORM_RESET';
Action creator is a function returning callback for dispatching an action. Within an action we can for example make an AJAX call and then dispatch one or many actions. Redux injects callable action to the component as a prop. When we want to trigger an action in the component, we simply call the action with required data passed as an argument.
Create two action creators for updating and resetting the form data:
// src/actions.js
import * as c from './constants';
export function update(name, value) {
return dispatch => dispatch({
type: c.FORM_UPDATE_VALUE,
name, value
});
}
export function reset() {
return dispatch => dispatch({
type: c.FORM_RESET
});
}
These actions will be passed into the Form
component.
Now we can respond to the actions and modify data in the model using reducers. Reducer is another function, which takes the current state and returns the new one based on the action. Redux puts the new state to the components and View is being re-rendered.
Install lodash.assign (npm i -S lodash.assign
) and create the store:
// src/store.js
import * as c from './constants';
import assign from 'lodash.assign';
const initialState = { //define initial state - an empty form
values: {}
};
export default (state = initialState, action) => {
switch (action.type) {
case c.FORM_UPDATE_VALUE:
return assign({}, state, {
values: assign({}, state.values, {
[action.name]: action.value
})
});
case c.FORM_RESET:
return initialState;
default:
return state;
}
}
In redux, our model is a reducer – simple function which reduces the current (or initial) state to the new one based on the action data (so it works in the same way as function passed to Array.prototype.reduce)
Important! If we modify the state, the reference of the state must also change (redux propagates changes by checking references), so always create the new objects – that’s why I used assign({}, ...)
in the return statements above.
Now connect action creators, store, and the root component together.
Install extra modules required to use redux with our component: npm install --S redux react-redux redux-thunk redux-logger react-tap-event-plugin@^0.2.0
Create a wrapper for the Form
:
// src/index.js
import React, {PropTypes} from 'react';
import { createStore, applyMiddleware, compose } from 'redux';
import { connect } from 'react-redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
import Form from './components/Form';
import * as actions from './actions';
import store from './store';
import injectTapEventPlugin from 'react-tap-event-plugin';
injectTapEventPlugin();
const SmartForm = connect(state => state, actions)(Form);
const reduxMiddleware = applyMiddleware(thunk, createLogger());
export default props => (
<Provider store={compose(reduxMiddleware)(createStore)(store)}> <SmartForm {...props}/> </Provider>
);
A few lines, but also lots of the new code:
connect()
takes the React component, a function returning the current state and actions. It returns the smart component with all three elements bound together.<Provider>
is responsible for connecting all its smart children with the actual store.reduxMiddleware
is a simple middleware between dispatching actions and calling reducers. In this case we compose two middleware elements: redux-thunk, and redux-logger: -redux-thunk
is used for dispatching asynchronous actions (read more),redux-logger
is used for logging all dispatched actions in the console so we can easily track them.injectTapEventPlugin
is a fix required by material-ui
Text field
Create the first visible element – a text field. Install material-ui and create the component:
// src/components/Text.js
import React, {PropTypes} from 'react';
import TextField from 'material-ui/lib/text-field';
export default React.createClass({
displayName: 'Text',
propTypes: {
name: PropTypes.string.isRequired,
placeholder: PropTypes.string,
label: PropTypes.string
},
render() {
return (
<div> <TextField hintText={this.props.placeholder} floatingLabelText={this.props.label}/> </div>
);
}
});
The code above wraps TextField
and passes the props. We have to export this component to allow the programmer for importing it – add the following line at the end of index.js
:
// index.js
//...
export {default as Text} from './components/Text';
Let’s try to render the first example to see how the rendering code will look like:
// demo.js
import React from 'react';
import ReactDOM from 'react-dom';
import Form, {Text} from '.src/index';
ReactDOM.render((
<Form>
<Text
name="name"
placeholder="Type your name here"
label="Your name"/>
</Form>
), document.getElementById('container'));s
There is a problem: how to update the model in Form
when user types some text? In the above example we actually defined all we need – the model is stored in Form
, name of the field is defined too, our library has all required information.
Let’s not spoil this simplicity: we can hide the passing of data between components inside the implementation of our library. We have to use React’s feature called context.
Using React context
Occasionally, you want to pass data through the component tree without having to pass the props down manually at every level. React’s “context” feature lets you do this.
Add the following code to src/components/Form.js
:
// src/components/Form.js
import React, {PropTypes} from 'react';
export default React.createClass({
//...
childContextTypes: {
update: PropTypes.func,
reset: PropTypes.func,
submit: PropTypes.func,
values: PropTypes.object
},
getChildContext() {
return {
update: this.props.update,
reset: this.props.reset,
submit: this.submit,
values: this.props.values
};
},
//...
});
Form
now exports the model and actions via the context so they can be used in its child components.
Update Text
to see how it works:
// src/components/Text.js
import React, { PropTypes } from 'react';
import TextField from 'material-ui/lib/text-field';
export default React.createClass({
displayName: 'Text',
propTypes: {
name: PropTypes.string.isRequired,
placeholder: PropTypes.string,
label: PropTypes.string
},
contextTypes: {
update: PropTypes.func.isRequired,
values: PropTypes.object.isRequired
},
updateValue(value) {
this.context.update(this.props.name, value);
},
onChange(event) {
this.updateValue(event.target.value)
},
render() {
return (
<div> <TextField hintText={this.props.placeholder} floatingLabelText={this.props.label} value={this.context.values[this.props.name]} onChange={this.onChange}/> </div>
);
}
});
- value is removed from the props, we get it from the context now: this.context.values[this.props.name]
- model is updated on input change via the action in context:
this.context.update(this.props.name, value)
Let’s see how it looks. Run npm run build
if you haven’t already, and open dist/demo.html
in a browser:
Validation
Now if we are able to compose text fields in the form, it’s time to implement some validation. Let’s start with implementing three example rules (install valid-url and email-validator before):
// src/validators.js
import validUrl from 'valid-url';
import emailValidator from 'email-validator';
export function required(value) {
return !value ? ['This field cannot be empty'] : [];
}
export function url(value) {
return value && !validUrl.isWebUri(value) ? ['This URL is invalid'] : [];
}
export function email(value) {
return !emailValidator.validate(value) ? ['This email address is invalid']: [];
}
Each validator is a function returning an array with errors. If the returned array is empty – validation has passed.
But how to apply these validators to the Text
field? Take a look:
// src/components/Text.js
import React, { PropTypes } from 'react';
import TextField from 'material-ui/lib/text-field';
import * as validators from '../validators';
export default React.createClass({
displayName: 'Text',
propTypes: {
name: PropTypes.string.isRequired,
placeholder: PropTypes.string,
label: PropTypes.string,
validate: PropTypes.arrayOf(PropTypes.string)
},
contextTypes: {
update: PropTypes.func.isRequired,
values: PropTypes.object.isRequired
},
getDefaultProps() {
return {
validate: []
}
},
getInitialState() {
return {
errors: []
};
},
updateValue(value) {
this.context.update(this.props.name, value);
if (this.state.errors.length) {
setTimeout(() => this.isValid(true), 0);
}
},
onChange(event) {
this.updateValue(event.target.value)
},
isValid(showErrors) {
const errors = this.props.validate
.reduce((memo, currentName) =>
memo.concat(validators[currentName](
this.context.values[this.props.name]
)), []);
if (showErrors) {
this.setState({
errors
});
}
return !errors.length;
},
onBlur() {
this.isValid(true);
},
render() {
return (
<div> <TextField hintText={this.props.placeholder} floatingLabelText={this.props.label} value={this.context.values[this.props.name]} onChange={this.onChange} onBlur={this.onBlur} errorText={this.state.errors.length ? ( <div> {this.state.errors.map((error, i) => <div key={i}>{error}</div>)} </div> ) : null}/> </div>
);
}
});
- validate
has been added to the props, so programmer can define an array with rule names that should be applied.
isValid
is called on blur. It iterates through the rules, call each of them and returns array with the errors- text with errors is passed to the
TextField
updateValue
has changed slightly. When user gets back to the field with errors, we refresh validation on each change. If the value is correct, errors should disappear immediately.
Update the demo:
// demo.js
import React from 'react';
import ReactDOM from 'react-dom';
import Form, {Text} from './src/index';
ReactDOM.render((
<Form> <Text name="name" validate={['required']} placeholder="Type your name here" label="Your name"/> <Text name="email" validate={['required', 'email']} placeholder="Type your email here" label="E-mail"/> <Text name="name" validate={['url']} placeholder="Type your website url here" label="Website"/> </Form>
), document.getElementById('container'));
Refresh the demo and see how it works:
Before we submit the form, we need to check the validation status in all nested elements and prevent submission if one of the field is filled incorrectly.
The idea is to register isValid
method in Form
component from the field component, using the context (install lodash.without before):
// src/components/Form.js
import React, {PropTypes} from 'react';
import without from 'lodash.without';
import assign from 'lodash.assign';
const noop = () => undefined;
export default React.createClass({
propTypes: {
children: PropTypes.node,
values: PropTypes.object,
update: PropTypes.func,
reset: PropTypes.func,
onSubmit: PropTypes.func
},
childContextTypes: {
update: PropTypes.func,
reset: PropTypes.func,
submit: PropTypes.func,
values: PropTypes.object,
registerValidation: PropTypes.func,
isFormValid: PropTypes.func,
},
getDefaultProps() {
return {
onSubmit: noop
};
},
validations: [],
registerValidation(isValidFunc) {
this.validations = [...this.validations, isValidFunc];
return this.removeValidation.bind(null, isValidFunc);
},
removeValidation(ref) {
this.validations = without(this.validations, ref);
},
isFormValid(showErrors) {
return this.validations.reduce((memo, isValidFunc) =>
isValidFunc(showErrors) && memo, true);
},
submit(){
if (this.isFormValid(true)) {
this.props.onSubmit(assign({}, this.props.values));
this.props.reset();
}
},
getChildContext() {
return {
update: this.props.update,
reset: this.props.reset,
submit: this.submit,
values: this.props.values,
registerValidation: this.registerValidation,
isFormValid: this.isFormValid
};
},
render() {
return (
<form>
{this.props.children}
</form>
);
}
});
- registerValidation
adds a reference of the validating function to the array (used when field component is mounted) and returns another function removing the same reference from the register
isFormValid
checks registered validation functions and returnstrue
orfalse
. This method is also injected into the context, so all nested components can check if the form is valid or notsubmit
checks if the form is valid, sends copy of the model to the callback function and resets the model to the initial state (seereset
action creator)
Update Text
component as well:
// src/components/Text.js
import React, { PropTypes } from 'react';
import TextField from 'material-ui/lib/text-field';
import * as validators from '../validators';
export default React.createClass({
//...
contextTypes: {
update: PropTypes.func.isRequired,
values: PropTypes.object.isRequired,
registerValidation: PropTypes.func.isRequired
},
componentWillMount() {
this.removeValidationFromContext = this.context.registerValidation(show =>
this.isValid(show));
},
componentWillUnmount() {
this.removeValidationFromContext();
},
//...
});
Validation method is registered on mounting, removeValidationFromContext
is stored and called on unmounting.
Serialization
Once we can check form validation locally and globally, let’s implement the submit button:
// src/components/SubmitButton.js
import React, { PropTypes } from 'react';
import RaisedButton from 'material-ui/lib/raised-button';
export default React.createClass({
displayName: 'SubmitButton',
propTypes: {
label: PropTypes.string
},
contextTypes: {
isFormValid: PropTypes.func.isRequired,
submit: PropTypes.func.isRequired
},
getDefaultProps() {
return {
label: 'Submit'
};
},
render() {
return (
<div> <RaisedButton primary disabled={!this.context.isFormValid()} label={this.props.label} onTouchTap={this.context.submit}/> </div>
);
}
});
- isFormValid
is taken from the context
- If
isFormValid
returns false, the button is disabled - When button is clicked, we call
submit
action from the context
Export the new component same as others:
// src/index.js
//...
export {default as SubmitButton} from './components/SubmitButton';
Update the demo: and see how it works:
// demo.js
import React from 'react';
import ReactDOM from 'react-dom';
import Form, {Text, SubmitButton} from './src/index';
ReactDOM.render((
<Form onSubmit={data => console.log(data)}> <Text name="name" validate={['required']} placeholder="Type your name here" label="Your name"/> <Text name="email" validate={['required', 'email']} placeholder="Type your email here" label="E-mail"/> <Text name="website" validate={['url']} placeholder="Type your website url here" label="Website"/> <SubmitButton/> </Form>
), document.getElementById('container'));
Summary
Congratulations! We have learned a few nice things together:
- how to set up new ES2015 project using webpack and babel
- how to use redux
- how to use React’s context
- how to take advantage of JSX design to create simple composition concept
- how to hide implementation details away in the library
The final code is available on GitHub.
Now, if you get the idea, you can freely scale your new library to handle all types of fields: passwords, check boxes, radio groups, datepickers, selects and literally any custom inputs you can imagine.
Also, these ideas are worth considering:
- try refactoring validators and custom fields into separate npm modules and compose them as needed rather than grouping everything in the one library
- create additional layer of abstraction to make switching from material-ui to other components possible (eg. if you want to apply your own styles)
- initialize new fields with default value using context to avoid missing properties in the model
If you have any questions, catch me on Twitter (@KasperWargula).
Extra resources
Up next
Lorem ipsum dolor sit amet, consectetur adipiscing elit.