7 Patterns to Refactor JavaScript Applications: Form Objects
Forms often have complex logic applied to them. In general, the logic breaks down into the following categories: validation, persistence, and feedback.
A Form Object can encapsulate all associated logic into a single object, keeping it focused, isolated, and easy to test. If you have a signup form that creates an account, an associated Form Object could handle the following logic:
- Make sure all fields that are required are present
- Make sure all values are valid (password is long enough, username isn’t taken, etc.)
- Send the form to the server so the user is saved to the database
- Provide success or error feedback to the user
Placing model validations on the Form Object instead of on a centralized model is perhaps counter-intuitive, since you may have to repeat these validations across multiple Form Objects that affect the same model. However, localizing validations to the form increases code readability (if you’re working on a form, you can just look at the Form Object) but also avoids cluttering your model with logic that is only used in form operations. Form Objects also give you fine-tuned control over the validations in their specific context and do not force you to guard against all scenarios on the model.
In fact, if we think about it on a higher level, we are off-loading the concept of a Model to the Form Objects and treating our “Model” objects like Data Access Objects (DAOs). If this is to be true, there has to be a bond of trust between the Model and the Form Object that what is being sent to the model is pure (i.e. well-formed, valid, complete). From an application architecture standpoint, this can be a really nice design pattern.
So, let’s take a look at two examples. One of them demonstrating a full Form Object that covers all form operations and another demonstrating a Validation Object—an object describing validations that can be sequenced with other components.
Examples
Let’s imagine a teacher is registering new students for the school year. The application can hand the form data off to the Form Object for handling all aspects of its processing flow:
var _ = require('underscore')
var NewStudentForm = function(data) {
this.data = data
this.errors = [] // for storing validation errors encountered
}
NewStudentForm.prototype = _.extend(NewStudentForm.prototype, {
process: function() {
if (this.isValid()) {
this.persist() // only persist if data is valid
}
return this
},
isValid: function() {
// perform all validations, and store
// errors if validations fail
if (_.isUndefined(this.data.firstName)) {
this.errors.push(new Error('first name is required'))
}
if (_.isUndefined(this.data.lastName)) {
this.errors.push(new Error('last name is required'))
}
// ...
// return boolean representing if
// any errors were encountered during validation
return this.errors.length === 0
},
persist: function() {
// off-load persistence to a service object
return new CreateNewStudent(this.data).run()
}
})
This form gives us a short, expressive API for executing this form in our main application components, like in an Express.js route handler:
var myController = function(req, res) {
var form = new NewStudentForm(req.body).process()
if (form.errors.length > 0) {
// send the user back to the form
} else {
// send the user to the success page
}
}
One of the great things about Form Objects in the JavaScript environment is their potential for reuse. We may want to validate the form on the client-side before it is ever sent up to the server for processing, but we wouldn’t want to only validate on the client since a user can manipulate those validations, so we also want to have it guard on the server. We may also want to guard against an API call, for when data is being submitted by a third-party application that is not necessarily making use of our client-side validation code.
If we think creatively about the composition of Form Objects, we can create a consistent API in the client code and server code. For example, if instead of a Form Object that encompasses all aspects of form processing, we create a Validation Object that only guards form values, we can use it to compose consistent, expressive and context-specific process flows:
var _ = require('underscore')
var NewStudentFormValidator = function() {
this.errors = [] // for storing validation errors encountered
}
NewStudentFormValidator.prototype = _.extend(NewStudentFormValidator.prototype, {
validate: function(data) {
// call each validation method, storing
// any errors on the this.errors array
_.compose(
this.validateFirstName,
this.validateLastName,
).call(this, data)
return this
},
// predicate identifying valid status
// based on this.errors.length
isValid: function() {
return this.errors.length === 0
},
validateFirstName: function(data) {
if (_.isUndefined(data.firstName)) {
this.errors.push(new Error('first name is required'))
}
return data
},
validateLastName: function(data) {
if (_.isUndefined(data.lastName)) {
this.errors.push(new Error('last name is required'))
}
return data
}
})
This is nice because we can favor flexible composition over large, monolithic Form Objects.
// Client-side
var validator = new NewStudentFormValidator().validate(data)
if (validator.isValid()) {
// submit form to server
} else {
// show errors to user
}
// Server-side (application route)
var validator = new NewStudentFormValidator().validate(data)
if (validator.isValid()) {
return new CreateNewStudent(data).run()
// send user to success page
} else {
// send user back to the form page with errors
}
// Server-side (API route)
var validator = new NewStudentFormValidator().validate(data)
if (validator.isValid()) {
return new CreateNewStudent(data).run()
// send 201 with new student JSON
} else {
// send error code
}
You see how we defined the Validator Object once and can use it in each entry point to the database, guarding it consistently across all fronts. This approach can help keep things DRY and organized, but if you find it easier to see everything in one Form Object instead of having the process divided up between specialized components, that’s completely valid too. It’s all about what type of composition feels best to you and your team.
Testing
No matter how you compose your Form Objects, testing is made simpler by extracting it out of the application stack. All you have to do is define an object representing the form data you want to test, and send it through. It’s a good practice to make sure you test the error handling, too, to make sure that all applicable errors are sent back to the application for messaging to the user.
var _ = require('underscore')
var expect = require('chai').expect
var sinon = require("sinon")
var sinonChai = require("sinon-chai")
chai.use(sinonChai)
var NewStudentForm = require('./NewStudentForm')
describe('NewStudentForm', function(){
describe('Valid Form Data', function(){
before(function(){
// spy on the this.persist method to see if it's called or not
this.persistSpy = sinon.spy(NewStudentForm.prototype, 'persist')
this.newStudentForm = new NewStudentForm({
firstName: 'John',
lastName: 'Smith',
gender: 'm'
}).process()
})
after(function() {
this.persistSpy.restore()
})
it('persists the data', function(){
expect(this.persistSpy).to.have.beenCalled
})
it('does not have errors', function(){
expect(this.newStudentForm.errors.length).to.eq(0)
})
})
})
Conclusion
Using Form Objects is a great way to keep input validation, persistence, feedback, or any other domain logic that is specific to form operations, localized and readable. Not only that, it makes testing form logic much simpler when you have a single object that tracks form state. And with JavaScript, you can use these objects across your entire stack.
In the next post, we’ll take a look at Query Objects, which are used for retrieving records from the database or filtering down a collection.
The post was originally written for the Crush & Lovely Blog in 2012 and has been lovingly brought up-to-date for Engine Yard by Michael Phillips, the original author. Special thanks to Justin Reidy and Anisha Vasandani for help with the original.
Share your thoughts with @engineyard on Twitter
OR
Talk about it on reddit